Compare commits

..

4 Commits

79 changed files with 3431 additions and 5351 deletions

View File

@ -1,91 +0,0 @@
name: CLI E2E Tests
on:
workflow_dispatch:
inputs:
cli_ref:
description: "Git ref to build the CLI from (default: current branch)"
type: string
required: false
test_scope:
description: "Test scope to run"
type: choice
required: false
default: smoke
options:
- smoke # [P0] cases only — fast
- full # all cases
permissions:
contents: read
jobs:
e2e:
name: E2E — difyctl
runs-on: ubuntu-latest
timeout-minutes: 30
defaults:
run:
shell: bash
steps:
# ── Checkout ───────────────────────────────────────────────────────────
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
# ── Runtime setup ──────────────────────────────────────────────────────
- name: Setup web environment
uses: ./.github/actions/setup-web
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
# Re-initialise pnpm to match the CLI's packageManager (pnpm@11.x).
# setup-web installs pnpm@9 via setup-vp; this step overrides it so
# the CLI workspace uses the correct version declared in cli/package.json.
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with:
package_json_field: packageManager
run_install: false
- name: Install CLI dependencies
working-directory: cli
run: pnpm install --frozen-lockfile
- name: Generate command tree
working-directory: cli
run: pnpm tree:gen
# ── Run E2E tests ──────────────────────────────────────────────────────
- name: Run E2E tests (${{ inputs.test_scope || 'smoke' }})
working-directory: cli
env:
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
DIFY_E2E_TOKEN: ${{ secrets.DIFY_E2E_TOKEN }}
DIFY_E2E_WORKSPACE_ID: ${{ secrets.DIFY_E2E_WORKSPACE_ID }}
DIFY_E2E_WORKSPACE_NAME: ${{ secrets.DIFY_E2E_WORKSPACE_NAME }}
DIFY_E2E_CHAT_APP_ID: ${{ secrets.DIFY_E2E_CHAT_APP_ID }}
DIFY_E2E_WORKFLOW_APP_ID: ${{ secrets.DIFY_E2E_WORKFLOW_APP_ID }}
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
DIFY_E2E_HITL_APP_ID: ${{ secrets.DIFY_E2E_HITL_APP_ID }}
DIFY_E2E_FILE_APP_ID: ${{ secrets.DIFY_E2E_FILE_APP_ID }}
run: |
if [ "${{ inputs.test_scope }}" = "full" ]; then
pnpm test:e2e
else
pnpm test:e2e:smoke
fi
# ── Upload results ─────────────────────────────────────────────────────
- name: Upload test results on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-results-${{ github.run_id }}
path: cli/test-results/
retention-days: 3

View File

@ -5,7 +5,7 @@ from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from libs.login import current_account_with_tenant, login_required
from models.model import App, AppMode
from models.model import AppMode
from services.agent.composer_service import AgentComposerService
from services.agent.composer_validator import ComposerConfigValidator
from services.entities.agent_entities import ComposerSavePayload
@ -19,7 +19,7 @@ class WorkflowAgentComposerApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def get(self, app_model: App, node_id: str):
def get(self, app_model, node_id: str):
_, tenant_id = current_account_with_tenant()
return AgentComposerService.load_workflow_composer(
tenant_id=tenant_id,
@ -33,7 +33,7 @@ class WorkflowAgentComposerApi(Resource):
@account_initialization_required
@edit_permission_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def put(self, app_model: App, node_id: str):
def put(self, app_model, node_id: str):
account, tenant_id = current_account_with_tenant()
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return AgentComposerService.save_workflow_composer(
@ -52,7 +52,7 @@ class WorkflowAgentComposerValidateApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def post(self, app_model: App, node_id: str):
def post(self, app_model, node_id: str):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
return {"result": "success", "errors": []}
@ -64,7 +64,7 @@ class WorkflowAgentComposerCandidatesApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def get(self, app_model: App, node_id: str):
def get(self, app_model, node_id: str):
return AgentComposerService.get_workflow_candidates(app_id=app_model.id)
@ -74,7 +74,7 @@ class WorkflowAgentComposerImpactApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def post(self, app_model: App, node_id: str):
def post(self, app_model, node_id: str):
_, tenant_id = current_account_with_tenant()
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
current_snapshot_id = payload.binding.current_snapshot_id if payload.binding else None
@ -91,7 +91,7 @@ class WorkflowAgentComposerSaveToRosterApi(Resource):
@account_initialization_required
@edit_permission_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def post(self, app_model: App, node_id: str):
def post(self, app_model, node_id: str):
account, tenant_id = current_account_with_tenant()
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return AgentComposerService.save_workflow_composer(
@ -109,7 +109,7 @@ class AgentAppComposerApi(Resource):
@login_required
@account_initialization_required
@get_app_model()
def get(self, app_model: App):
def get(self, app_model):
_, tenant_id = current_account_with_tenant()
return AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_model.id)
@ -119,7 +119,7 @@ class AgentAppComposerApi(Resource):
@account_initialization_required
@edit_permission_required
@get_app_model()
def put(self, app_model: App):
def put(self, app_model):
account, tenant_id = current_account_with_tenant()
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return AgentComposerService.save_agent_app_composer(
@ -137,7 +137,7 @@ class AgentAppComposerValidateApi(Resource):
@login_required
@account_initialization_required
@get_app_model()
def post(self, app_model: App):
def post(self, app_model):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
return {"result": "success", "errors": []}
@ -149,5 +149,5 @@ class AgentAppComposerCandidatesApi(Resource):
@login_required
@account_initialization_required
@get_app_model()
def get(self, app_model: App):
def get(self, app_model):
return AgentComposerService.get_agent_app_candidates(app_id=app_model.id)

View File

@ -8,7 +8,7 @@ from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required
from libs.helper import uuid_value
from libs.login import login_required
from models.model import App, AppMode
from models.model import AppMode
from services.agent_service import AgentService
@ -39,7 +39,7 @@ class AgentLogApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT_CHAT])
def get(self, app_model: App):
def get(self, app_model):
"""Get agent logs"""
args = AgentLogQuery.model_validate(request.args.to_dict(flat=True))

View File

@ -573,7 +573,7 @@ class AppApi(Resource):
@account_initialization_required
@enterprise_license_required
@get_app_model(mode=None)
def get(self, app_model: App):
def get(self, app_model):
"""Get app detail"""
app_service = AppService()
@ -581,7 +581,7 @@ class AppApi(Resource):
if FeatureService.get_system_features().webapp_auth.enabled:
app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id))
app_model.access_mode = app_setting.access_mode # type: ignore[attr-defined]
app_model.access_mode = app_setting.access_mode
response_model = AppDetailWithSite.model_validate(app_model, from_attributes=True)
return response_model.model_dump(mode="json")
@ -598,7 +598,7 @@ class AppApi(Resource):
@account_initialization_required
@get_app_model(mode=None)
@edit_permission_required
def put(self, app_model: App):
def put(self, app_model):
"""Update app"""
args = UpdateAppPayload.model_validate(console_ns.payload)
@ -627,7 +627,7 @@ class AppApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
def delete(self, app_model: App):
def delete(self, app_model):
"""Delete app"""
app_service = AppService()
app_service.delete_app(app_model)
@ -648,7 +648,7 @@ class AppCopyApi(Resource):
@account_initialization_required
@get_app_model(mode=None)
@edit_permission_required
def post(self, app_model: App):
def post(self, app_model):
"""Copy app"""
# The role of the current user in the ta table must be admin, owner, or editor
current_user, _ = current_account_with_tenant()
@ -709,7 +709,7 @@ class AppExportApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
def get(self, app_model: App):
def get(self, app_model):
"""Export app"""
args = AppExportQuery.model_validate(request.args.to_dict(flat=True))
@ -731,7 +731,7 @@ class AppPublishToCreatorsPlatformApi(Resource):
@account_initialization_required
@get_app_model(mode=None)
@edit_permission_required
def post(self, app_model: App):
def post(self, app_model):
"""Publish app to Creators Platform"""
from configs import dify_config
from core.helper.creators import get_redirect_url, upload_dsl
@ -762,7 +762,7 @@ class AppNameApi(Resource):
@account_initialization_required
@get_app_model(mode=None)
@edit_permission_required
def post(self, app_model: App):
def post(self, app_model):
args = AppNamePayload.model_validate(console_ns.payload)
app_service = AppService()
@ -784,7 +784,7 @@ class AppIconApi(Resource):
@account_initialization_required
@get_app_model(mode=None)
@edit_permission_required
def post(self, app_model: App):
def post(self, app_model):
args = AppIconPayload.model_validate(console_ns.payload or {})
app_service = AppService()
@ -811,7 +811,7 @@ class AppSiteStatus(Resource):
@account_initialization_required
@get_app_model(mode=None)
@edit_permission_required
def post(self, app_model: App):
def post(self, app_model):
args = AppSiteStatusPayload.model_validate(console_ns.payload)
app_service = AppService()
@ -833,7 +833,7 @@ class AppApiStatus(Resource):
@is_admin_or_owner_required
@account_initialization_required
@get_app_model(mode=None)
def post(self, app_model: App):
def post(self, app_model):
args = AppApiStatusPayload.model_validate(console_ns.payload)
app_service = AppService()
@ -874,7 +874,7 @@ class AppTraceApi(Resource):
@account_initialization_required
@edit_permission_required
@get_app_model
def post(self, app_model: App):
def post(self, app_model):
# add app trace
args = AppTracePayload.model_validate(console_ns.payload)

View File

@ -70,7 +70,7 @@ class ChatMessageAudioApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
def post(self, app_model: App):
def post(self, app_model):
file = request.files["file"]
try:
@ -171,7 +171,7 @@ class TextModesApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model: App):
def get(self, app_model):
try:
args = TextToSpeechVoiceQuery.model_validate(request.args.to_dict(flat=True))

View File

@ -33,7 +33,7 @@ from libs import helper
from libs.helper import uuid_value
from libs.login import current_user, login_required
from models import Account
from models.model import App, AppMode
from models.model import AppMode
from services.app_generate_service import AppGenerateService
from services.app_task_service import AppTaskService
from services.errors.llm import InvokeRateLimitError
@ -84,7 +84,7 @@ class CompletionMessageApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=AppMode.COMPLETION)
def post(self, app_model: App):
def post(self, app_model):
args_model = CompletionMessagePayload.model_validate(console_ns.payload)
args = args_model.model_dump(exclude_none=True, by_alias=True)
@ -131,7 +131,7 @@ class CompletionMessageStopApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=AppMode.COMPLETION)
def post(self, app_model: App, task_id: str):
def post(self, app_model, task_id: str):
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
@ -159,7 +159,7 @@ class ChatMessageApi(Resource):
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT])
@edit_permission_required
def post(self, app_model: App):
def post(self, app_model):
args_model = ChatMessagePayload.model_validate(console_ns.payload)
args = args_model.model_dump(exclude_none=True, by_alias=True)
@ -212,7 +212,7 @@ class ChatMessageStopApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
def post(self, app_model: App, task_id: str):
def post(self, app_model, task_id: str):
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")

View File

@ -33,7 +33,7 @@ from fields.conversation_fields import (
from libs.datetime_utils import naive_utc_now, parse_time_range
from libs.login import current_account_with_tenant, login_required
from models import Conversation, EndUser, Message, MessageAnnotation
from models.model import App, AppMode
from models.model import AppMode
from services.conversation_service import ConversationService
from services.errors.conversation import ConversationNotExistsError
@ -93,7 +93,7 @@ class CompletionConversationApi(Resource):
@account_initialization_required
@get_app_model(mode=AppMode.COMPLETION)
@edit_permission_required
def get(self, app_model: App):
def get(self, app_model):
current_user, _ = current_account_with_tenant()
args = CompletionConversationQuery.model_validate(request.args.to_dict(flat=True))
@ -165,7 +165,7 @@ class CompletionConversationDetailApi(Resource):
@account_initialization_required
@get_app_model(mode=AppMode.COMPLETION)
@edit_permission_required
def get(self, app_model: App, conversation_id: UUID):
def get(self, app_model, conversation_id: UUID):
conversation_id_str = str(conversation_id)
return ConversationMessageDetailResponse.model_validate(
_get_conversation(app_model, conversation_id_str), from_attributes=True
@ -182,7 +182,7 @@ class CompletionConversationDetailApi(Resource):
@account_initialization_required
@get_app_model(mode=AppMode.COMPLETION)
@edit_permission_required
def delete(self, app_model: App, conversation_id: UUID):
def delete(self, app_model, conversation_id: UUID):
current_user, _ = current_account_with_tenant()
conversation_id_str = str(conversation_id)
@ -207,7 +207,7 @@ class ChatConversationApi(Resource):
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
@edit_permission_required
def get(self, app_model: App):
def get(self, app_model):
current_user, _ = current_account_with_tenant()
args = ChatConversationQuery.model_validate(request.args.to_dict(flat=True))
@ -318,7 +318,7 @@ class ChatConversationDetailApi(Resource):
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
@edit_permission_required
def get(self, app_model: App, conversation_id: UUID):
def get(self, app_model, conversation_id: UUID):
conversation_id_str = str(conversation_id)
return ConversationDetailResponse.model_validate(
_get_conversation(app_model, conversation_id_str), from_attributes=True
@ -335,7 +335,7 @@ class ChatConversationDetailApi(Resource):
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
@account_initialization_required
@edit_permission_required
def delete(self, app_model: App, conversation_id: UUID):
def delete(self, app_model, conversation_id: UUID):
current_user, _ = current_account_with_tenant()
conversation_id_str = str(conversation_id)

View File

@ -19,7 +19,7 @@ from fields.base import ResponseModel
from libs.helper import to_timestamp
from libs.login import login_required
from models import ConversationVariable
from models.model import App, AppMode
from models.model import AppMode
class ConversationVariablesQuery(BaseModel):
@ -94,7 +94,7 @@ class ConversationVariablesApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=AppMode.ADVANCED_CHAT)
def get(self, app_model: App):
def get(self, app_model):
args = ConversationVariablesQuery.model_validate(request.args.to_dict(flat=True))
stmt = (

View File

@ -17,7 +17,7 @@ from fields.base import ResponseModel
from libs.helper import to_timestamp
from libs.login import current_account_with_tenant, login_required
from models.enums import AppMCPServerStatus
from models.model import App, AppMCPServer
from models.model import AppMCPServer
class MCPServerCreatePayload(BaseModel):
@ -73,7 +73,7 @@ class AppMCPServerController(Resource):
@account_initialization_required
@setup_required
@get_app_model
def get(self, app_model: App):
def get(self, app_model):
server = db.session.scalar(select(AppMCPServer).where(AppMCPServer.app_id == app_model.id).limit(1))
if server is None:
return {}
@ -92,7 +92,7 @@ class AppMCPServerController(Resource):
@login_required
@setup_required
@edit_permission_required
def post(self, app_model: App):
def post(self, app_model):
_, current_tenant_id = current_account_with_tenant()
payload = MCPServerCreatePayload.model_validate(console_ns.payload or {})
@ -127,7 +127,7 @@ class AppMCPServerController(Resource):
@setup_required
@account_initialization_required
@edit_permission_required
def put(self, app_model: App):
def put(self, app_model):
payload = MCPServerUpdatePayload.model_validate(console_ns.payload or {})
server = db.session.get(AppMCPServer, payload.id)
if not server:

View File

@ -45,7 +45,7 @@ from libs.helper import to_timestamp, uuid_value
from libs.infinite_scroll_pagination import InfiniteScrollPagination
from libs.login import current_account_with_tenant, login_required
from models.enums import FeedbackFromSource, FeedbackRating
from models.model import App, AppMode, Conversation, Message, MessageAnnotation, MessageFeedback
from models.model import AppMode, Conversation, Message, MessageAnnotation, MessageFeedback
from services.errors.conversation import ConversationNotExistsError
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
from services.message_service import MessageService, attach_message_extra_contents
@ -180,7 +180,7 @@ class ChatMessageListApi(Resource):
@setup_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
@edit_permission_required
def get(self, app_model: App):
def get(self, app_model):
args = ChatMessagesQuery.model_validate(request.args.to_dict())
conversation = db.session.scalar(
@ -257,7 +257,7 @@ class MessageFeedbackApi(Resource):
@setup_required
@login_required
@account_initialization_required
def post(self, app_model: App):
def post(self, app_model):
current_user, _ = current_account_with_tenant()
args = MessageFeedbackPayload.model_validate(console_ns.payload)
@ -314,7 +314,7 @@ class MessageAnnotationCountApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model: App):
def get(self, app_model):
count = db.session.scalar(
select(func.count(MessageAnnotation.id)).where(MessageAnnotation.app_id == app_model.id)
)
@ -337,7 +337,7 @@ class MessageSuggestedQuestionApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
def get(self, app_model: App, message_id: UUID):
def get(self, app_model, message_id: UUID):
current_user, _ = current_account_with_tenant()
message_id_str = str(message_id)
@ -379,7 +379,7 @@ class MessageFeedbackExportApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model: App):
def get(self, app_model):
args = FeedbackExportQuery.model_validate(request.args.to_dict())
# Import the service function
@ -417,7 +417,7 @@ class MessageApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model: App, message_id: UUID):
def get(self, app_model, message_id: UUID):
message_id_str = str(message_id)
message = db.session.scalar(

View File

@ -16,7 +16,7 @@ from events.app_event import app_model_config_was_updated
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from libs.login import current_account_with_tenant, login_required
from models.model import App, AppMode, AppModelConfig
from models.model import AppMode, AppModelConfig
from services.app_model_config_service import AppModelConfigService
@ -52,7 +52,7 @@ class ModelConfigResource(Resource):
@edit_permission_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT_CHAT, AppMode.CHAT, AppMode.COMPLETION])
def post(self, app_model: App):
def post(self, app_model):
"""Modify app model config"""
current_user, current_tenant_id = current_account_with_tenant()
# validate config

View File

@ -20,7 +20,6 @@ from fields.base import ResponseModel
from libs.datetime_utils import naive_utc_now
from libs.login import current_account_with_tenant, login_required
from models import Site
from models.model import App
class AppSiteUpdatePayload(BaseModel):
@ -85,7 +84,7 @@ class AppSite(Resource):
@edit_permission_required
@account_initialization_required
@get_app_model
def post(self, app_model: App):
def post(self, app_model):
args = AppSiteUpdatePayload.model_validate(console_ns.payload or {})
current_user, _ = current_account_with_tenant()
site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))
@ -134,7 +133,7 @@ class AppSiteAccessTokenReset(Resource):
@is_admin_or_owner_required
@account_initialization_required
@get_app_model
def post(self, app_model: App):
def post(self, app_model):
current_user, _ = current_account_with_tenant()
site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))

View File

@ -15,7 +15,6 @@ from libs.datetime_utils import parse_time_range
from libs.helper import convert_datetime_to_date
from libs.login import current_account_with_tenant, login_required
from models import AppMode
from models.model import App
class StatisticTimeRangeQuery(BaseModel):
@ -48,7 +47,7 @@ class DailyMessageStatistic(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model: App):
def get(self, app_model):
account, _ = current_account_with_tenant()
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
@ -62,12 +61,8 @@ FROM
WHERE
app_id = :app_id
AND invoke_from != :invoke_from"""
arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER}
assert account.timezone is not None
arg_dict: dict[str, object] = {
"tz": account.timezone,
"app_id": app_model.id,
"invoke_from": InvokeFrom.DEBUGGER,
}
try:
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)
@ -109,7 +104,7 @@ class DailyConversationStatistic(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model: App):
def get(self, app_model):
account, _ = current_account_with_tenant()
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
@ -123,12 +118,8 @@ FROM
WHERE
app_id = :app_id
AND invoke_from != :invoke_from"""
arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER}
assert account.timezone is not None
arg_dict: dict[str, object] = {
"tz": account.timezone,
"app_id": app_model.id,
"invoke_from": InvokeFrom.DEBUGGER,
}
try:
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)
@ -169,7 +160,7 @@ class DailyTerminalsStatistic(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model: App):
def get(self, app_model):
account, _ = current_account_with_tenant()
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
@ -183,12 +174,8 @@ FROM
WHERE
app_id = :app_id
AND invoke_from != :invoke_from"""
arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER}
assert account.timezone is not None
arg_dict: dict[str, object] = {
"tz": account.timezone,
"app_id": app_model.id,
"invoke_from": InvokeFrom.DEBUGGER,
}
try:
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)
@ -230,7 +217,7 @@ class DailyTokenCostStatistic(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model: App):
def get(self, app_model):
account, _ = current_account_with_tenant()
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
@ -245,12 +232,8 @@ FROM
WHERE
app_id = :app_id
AND invoke_from != :invoke_from"""
arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER}
assert account.timezone is not None
arg_dict: dict[str, object] = {
"tz": account.timezone,
"app_id": app_model.id,
"invoke_from": InvokeFrom.DEBUGGER,
}
try:
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)
@ -294,7 +277,7 @@ class AverageSessionInteractionStatistic(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
def get(self, app_model: App):
def get(self, app_model):
account, _ = current_account_with_tenant()
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
@ -316,12 +299,8 @@ FROM
WHERE
c.app_id = :app_id
AND m.invoke_from != :invoke_from"""
arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER}
assert account.timezone is not None
arg_dict: dict[str, object] = {
"tz": account.timezone,
"app_id": app_model.id,
"invoke_from": InvokeFrom.DEBUGGER,
}
try:
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)
@ -374,7 +353,7 @@ class UserSatisfactionRateStatistic(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model: App):
def get(self, app_model):
account, _ = current_account_with_tenant()
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
@ -392,12 +371,8 @@ LEFT JOIN
WHERE
m.app_id = :app_id
AND m.invoke_from != :invoke_from"""
arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER}
assert account.timezone is not None
arg_dict: dict[str, object] = {
"tz": account.timezone,
"app_id": app_model.id,
"invoke_from": InvokeFrom.DEBUGGER,
}
try:
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)
@ -444,7 +419,7 @@ class AverageResponseTimeStatistic(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=AppMode.COMPLETION)
def get(self, app_model: App):
def get(self, app_model):
account, _ = current_account_with_tenant()
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
@ -458,12 +433,8 @@ FROM
WHERE
app_id = :app_id
AND invoke_from != :invoke_from"""
arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER}
assert account.timezone is not None
arg_dict: dict[str, object] = {
"tz": account.timezone,
"app_id": app_model.id,
"invoke_from": InvokeFrom.DEBUGGER,
}
try:
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)
@ -505,7 +476,7 @@ class TokensPerSecondStatistic(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model: App):
def get(self, app_model):
account, _ = current_account_with_tenant()
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
@ -521,12 +492,8 @@ FROM
WHERE
app_id = :app_id
AND invoke_from != :invoke_from"""
arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER}
assert account.timezone is not None
arg_dict: dict[str, object] = {
"tz": account.timezone,
"app_id": app_model.id,
"invoke_from": InvokeFrom.DEBUGGER,
}
try:
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)

View File

@ -11,7 +11,7 @@ from extensions.ext_database import db
from libs.datetime_utils import parse_time_range
from libs.login import current_account_with_tenant, login_required
from models.enums import WorkflowRunTriggeredFrom
from models.model import App, AppMode
from models.model import AppMode
from repositories.factory import DifyAPIRepositoryFactory
@ -46,7 +46,7 @@ class WorkflowDailyRunsStatistic(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model: App):
def get(self, app_model):
account, _ = current_account_with_tenant()
args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True))
@ -86,7 +86,7 @@ class WorkflowDailyTerminalsStatistic(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model: App):
def get(self, app_model):
account, _ = current_account_with_tenant()
args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True))
@ -126,7 +126,7 @@ class WorkflowDailyTokenCostStatistic(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model: App):
def get(self, app_model):
account, _ = current_account_with_tenant()
args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True))
@ -166,7 +166,7 @@ class WorkflowAverageAppInteractionStatistic(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW])
def get(self, app_model: App):
def get(self, app_model):
account, _ = current_account_with_tenant()
args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True))

View File

@ -1,20 +0,0 @@
# E2E test environment variables
# Copy this file to .env.e2e and fill in real values before running tests.
# See test/e2e/setup/env.ts for documentation on each variable.
# Required
DIFY_E2E_HOST=https://your-staging-host.dify.ai
DIFY_E2E_TOKEN=dfoa_your_token_here
DIFY_E2E_WORKSPACE_ID=ws-your-workspace-id
DIFY_E2E_CHAT_APP_ID=app-echo-chat-id
DIFY_E2E_WORKFLOW_APP_ID=app-echo-workflow-id
# Optional (skip related tests when absent)
DIFY_E2E_SSO_TOKEN=
DIFY_E2E_HITL_APP_ID=
DIFY_E2E_FILE_APP_ID=
DIFY_E2E_WORKSPACE_NAME=
# For logout / devices revoke tests (mint disposable tokens via device flow API)
DIFY_E2E_EMAIL=
DIFY_E2E_PASSWORD=

8
cli/.gitignore vendored
View File

@ -4,10 +4,4 @@ node_modules/
*.tsbuildinfo
.vitest-cache/
docs/specs/
context/
# E2E test env (contains tokens/credentials — use .env.e2e.example instead)
.env.e2e
# Generated / runtime artifacts
oclif.manifest.json
npm-shrinkwrap.json
tmp/
context/

View File

@ -30,9 +30,6 @@
"dev": "bun bin/dev.js",
"test": "vp test",
"test:coverage": "vp test --coverage",
"test:e2e": "vp test --config vitest.e2e.config.ts",
"test:e2e:smoke": "vp test --config vitest.e2e.config.ts --testNamePattern \"\\[P0\\]\"",
"test:e2e:local": "DIFY_E2E_MODE=local vp test --config vitest.e2e.config.ts",
"lint": "eslint",
"lint:fix": "eslint --fix",
"type-check": "tsc",

View File

@ -1,19 +0,0 @@
# E2E test environment template — copy to .env.e2e and fill in the values.
# .env.e2e is git-ignored; this file is safe to commit.
#
# Required:
DIFY_E2E_HOST=
DIFY_E2E_TOKEN=
DIFY_E2E_WORKSPACE_ID=
DIFY_E2E_WORKSPACE_NAME=
DIFY_E2E_CHAT_APP_ID=
DIFY_E2E_WORKFLOW_APP_ID=
# Optional — skip related tests when absent:
DIFY_E2E_SSO_TOKEN=
DIFY_E2E_FILE_APP_ID=
DIFY_E2E_HITL_APP_ID=
# Used by global-setup to mint per-suite tokens (logout / devices tests):
DIFY_E2E_EMAIL=
DIFY_E2E_PASSWORD=

View File

@ -1,115 +0,0 @@
# Dify CLI — E2E Test Suite
End-to-end tests that exercise the **real `difyctl` binary** against a live
Dify server. Every test uses an isolated temporary config directory so no
state leaks between test files.
## Directory layout
```
test/e2e/
├── setup/
│ ├── env.ts — Load & validate DIFY_E2E_* env vars
│ ├── global-setup.ts — Health-check server + mint disposable token
│ └── global-teardown.ts — Delete conversations created during the run
├── helpers/
│ ├── cli.ts — run(), withAuthFixture(), mintFreshToken(),
│ │ injectAuth(), spawn_background()
│ ├── assert.ts — assertExitCode, assertJson, assertErrorEnvelope,
│ │ assertNoAnsi, assertPipeFriendlyJson, …
│ ├── cleanup-registry.ts — registerConversation() / cleanupRegisteredConversations()
│ ├── retry.ts — withRetry(fn, { attempts, delayMs })
│ └── skip.ts — optionalIt(), optionalDescribe()
└── suites/
├── auth/
│ ├── status.e2e.ts — auth status (text + JSON + SSO)
│ ├── use.e2e.ts — workspace switching
│ ├── whoami.e2e.ts — whoami + external SSO session checks
│ ├── devices.e2e.ts — devices list + revoke (runs near-last)
│ └── logout.e2e.ts — logout + local credential cleanup (runs last)
├── config/
│ └── config.e2e.ts — config path/get/set/unset/view, env override
└── run/
├── run-app-basic.e2e.ts — basic run, -o json, --inputs, streaming,
│ conversation, CI mode
├── run-app-streaming.e2e.ts — Ctrl+C / error-event / chunk timing
├── run-app-file.e2e.ts — --file upload (local + remote URL)
└── run-app-hitl.e2e.ts — HITL pause + resume
```
## Setup
Copy the credential template and fill in your values:
```bash
cp cli/.env.e2e.example cli/.env.e2e
# edit cli/.env.e2e with real credentials
```
### Required env vars
| Variable | Description |
| -------------------------- | -------------------------------------------------------- |
| `DIFY_E2E_HOST` | Staging server base URL (`http://localhost`) |
| `DIFY_E2E_TOKEN` | Internal user bearer token (`dfoa_…`) |
| `DIFY_E2E_WORKSPACE_ID` | Primary workspace ID |
| `DIFY_E2E_CHAT_APP_ID` | Chat app — outputs `echo: {query}` |
| `DIFY_E2E_WORKFLOW_APP_ID` | Workflow app — input `x` (required), outputs `echo: {x}` |
### Optional env vars
| Variable | Description |
| ------------------------- | ---------------------------------------------------- |
| `DIFY_E2E_SSO_TOKEN` | External SSO bearer token (`dfoe_…`) |
| `DIFY_E2E_HITL_APP_ID` | Workflow app with a Human-Input node |
| `DIFY_E2E_FILE_APP_ID` | Workflow app with a file input variable (`doc`) |
| `DIFY_E2E_WORKSPACE_NAME` | Display name for the primary workspace |
| `DIFY_E2E_EMAIL` | Console account email (enables disposable tokens) |
| `DIFY_E2E_PASSWORD` | Console account password (enables disposable tokens) |
> `DIFY_E2E_EMAIL` + `DIFY_E2E_PASSWORD` are used by `global-setup` and the
> `devices`/`logout` suites to mint fresh single-use `dfoa_` tokens via the
> device flow API, so those tests never revoke the shared `DIFY_E2E_TOKEN`.
## Running tests
```bash
cd cli
# Run the full E2E suite
bun run test:e2e
# Run only [P0] smoke cases
bun run test:e2e:smoke
# Run offline-safe config tests only (no network required)
bun run test:e2e:local
# Run a single file
bun vitest --config vitest.e2e.config.ts test/e2e/suites/auth/status.e2e.ts
```
## Test execution order
Files run sequentially (`fileParallelism: false`) in this order:
```
status → use → whoami → config → run (basic / streaming / file / HITL)
→ devices → logout
```
`devices` and `logout` run last because they revoke real server sessions.
## Design decisions
| Decision | Rationale |
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| **No mocking** | All HTTP traffic goes to the real server — this catches real integration regressions. |
| **Isolated config dirs** | Each test creates a fresh `withTempConfig()` dir; session state never leaks between tests. |
| **`withAuthFixture()`** | Combines `withTempConfig` + `injectAuth` into a single fixture; reduces beforeEach boilerplate. |
| **`injectAuth()` bypasses Device Flow** | Non-auth tests skip the browser step; only `auth/` suites exercise the real flow. |
| **`mintFreshToken()`** | `logout` and `devices-revoke` tests mint a disposable `dfoa_` token via the device flow API, so revoking it never kills the shared `DIFY_E2E_TOKEN`. |
| **Global `retry: 0`** | Flaky network calls use `withRetry()` locally with `shouldRetry` filtering; global retry masks non-idempotent failures (e.g. logout). |
| **Conversation cleanup** | `registerConversation()` + global-teardown delete staging conversations after the run. |

View File

@ -1,155 +0,0 @@
/**
* E2E assertion helpers.
*
* These wrap vitest's `expect` with richer failure messages that include the
* full stdout / stderr of the failing process — essential for debugging CI.
*/
import type { RunResult } from './cli.js'
import { expect } from 'vitest'
import './vitest-context.js'
// ── ANSI ──────────────────────────────────────────────────────────────────
// eslint-disable-next-line no-control-regex
const ANSI_RE = /\x1B\[[0-9;]*[mGKHFA-DJsuhl]/g
function redact(text: string): string {
return text
.replace(/\bBearer\s+[\w.-]+\b/g, 'Bearer [REDACTED]')
.replace(/\bdfo[ae]_[\w-]+\b/g, 'dfo*_REDACTED')
}
// ── Exit code ─────────────────────────────────────────────────────────────
/**
* Assert the exit code matches `expected`.
* On failure, prints the full stdout and stderr so the cause is visible in CI.
*/
export function assertExitCode(result: RunResult, expected: number): void {
if (result.exitCode !== expected) {
process.stderr.write(
`\n[E2E assertExitCode] expected ${expected}, got ${result.exitCode}\n`
+ `stdout:\n${redact(result.stdout) || '(empty)'}\n`
+ `stderr:\n${redact(result.stderr) || '(empty)'}\n`,
)
}
expect(result.exitCode, `exit code should be ${expected}`).toBe(expected)
}
/**
* Assert the exit code is NOT 0 (i.e. some error occurred).
*/
export function assertNonZeroExit(result: RunResult): void {
expect(result.exitCode, 'exit code should be non-zero').not.toBe(0)
}
// ── Stdout / stderr content ───────────────────────────────────────────────
/**
* Assert stdout is valid JSON and return the parsed value.
*/
export function assertJson<T = unknown>(result: RunResult): T {
let parsed: T
try {
parsed = JSON.parse(result.stdout) as T
}
catch {
throw new Error(
`stdout is not valid JSON.\nstdout:\n${redact(result.stdout)}\nstderr:\n${redact(result.stderr)}`,
)
}
return parsed
}
/**
* Assert stderr contains a valid JSON error envelope of the shape:
* { error: { code: string, message: string, hint?: string } }
*
* @param result - The run result to inspect.
* @param expectedCode - When provided, also asserts that error.code equals this value.
* Use the stable error codes from the CLI contract, e.g.:
* 'not_logged_in', 'app_not_found', 'insufficient_scope', 'auth_expired'
*
* @example
* assertErrorEnvelope(result, 'not_logged_in')
* assertErrorEnvelope(result, 'app_not_found')
*/
export function assertErrorEnvelope(
result: RunResult,
expectedCode?: string,
): { error: { code: string, message: string, hint?: string } } {
const raw = result.stderr.trim()
let parsed: { error: { code: string, message: string, hint?: string } }
try {
parsed = JSON.parse(raw) as typeof parsed
}
catch {
throw new Error(
`stderr is not valid JSON.\nstdout:\n${redact(result.stdout)}\nstderr:\n${redact(result.stderr)}`,
)
}
expect(parsed, 'stderr envelope missing "error" key').toHaveProperty('error')
expect(parsed.error, 'error.code must be a non-empty string').toHaveProperty('code')
expect(parsed.error, 'error.message must be a non-empty string').toHaveProperty('message')
expect(typeof parsed.error.code, 'error.code must be a string').toBe('string')
expect(parsed.error.code.length, 'error.code must be non-empty').toBeGreaterThan(0)
if (expectedCode !== undefined) {
expect(
parsed.error.code,
`error.code should be "${expectedCode}", got "${parsed.error.code}"\nstderr:\n${redact(result.stderr)}`,
).toBe(expectedCode)
}
return parsed
}
// ── ANSI / formatting ────────────────────────────────────────────────────
/**
* Assert the given text contains no ANSI escape sequences.
* Pass `label` to identify which stream failed (e.g. 'stdout', 'stderr').
*/
export function assertNoAnsi(text: string, label = 'output'): void {
const clean = text.replace(ANSI_RE, '')
expect(text, `${label} must not contain ANSI control codes`).toBe(clean)
}
/**
* Assert stdout starts with `{` and ends with `\n` — the canonical format
* for pipe-friendly JSON output.
*/
export function assertPipeFriendlyJson(result: RunResult): void {
assertNoAnsi(result.stdout, 'stdout')
expect(
result.stdout.trimStart().startsWith('{') || result.stdout.trimStart().startsWith('['),
'stdout should start with { or [ for pipe-friendly JSON',
).toBe(true)
expect(result.stdout.endsWith('\n'), 'stdout should end with newline').toBe(true)
}
// ── stdout / stderr contains ──────────────────────────────────────────────
/**
* Assert stdout contains the given substring, printing full output on failure.
*/
export function assertStdoutContains(result: RunResult, expected: string): void {
if (!result.stdout.includes(expected)) {
process.stderr.write(
`\n[E2E assertStdoutContains] "${expected}" not found in stdout.\n`
+ `stdout:\n${redact(result.stdout)}\nstderr:\n${redact(result.stderr)}\n`,
)
}
expect(result.stdout).toContain(expected)
}
/**
* Assert stderr contains the given substring, printing full output on failure.
*/
export function assertStderrContains(result: RunResult, expected: string): void {
if (!result.stderr.includes(expected)) {
process.stderr.write(
`\n[E2E assertStderrContains] "${expected}" not found in stderr.\n`
+ `stdout:\n${redact(result.stdout)}\nstderr:\n${redact(result.stderr)}\n`,
)
}
expect(result.stderr).toContain(expected)
}

View File

@ -1,93 +0,0 @@
/**
* E2E cleanup registry.
*
* Test suites call `registerConversation(host, token, appId, conversationId)`
* whenever a real conversation is created on staging. The global teardown
* iterates the registry and deletes all collected conversations so staging
* data stays clean between CI runs.
*
* Design notes:
* - Uses a module-level array (shared within the same worker process).
* - vitest runs E2E suites in a single fork (fileParallelism: false), so one
* process owns the full registry.
* - Deletion is best-effort: individual failures are logged but do not throw.
*/
export type ConversationEntry = {
host: string
token: string
appId: string
conversationId: string
}
const _conversations: ConversationEntry[] = []
/**
* Register a conversation for cleanup in teardown.
* Call this whenever `run app` returns a `conversation_id`.
*/
export function registerConversation(
host: string,
token: string,
appId: string,
conversationId: string,
): void {
if (!conversationId || !appId)
return
_conversations.push({ host, token, appId, conversationId })
}
/**
* Return all registered conversations (for use in teardown).
*/
export function getRegisteredConversations(): readonly ConversationEntry[] {
return _conversations
}
/**
* Delete all registered conversations from the staging server.
* Called once from global-teardown.ts.
*/
export async function cleanupRegisteredConversations(): Promise<void> {
if (_conversations.length === 0)
return
console.log(`[E2E teardown] Cleaning up ${_conversations.length} staged conversation(s)…`)
const results = await Promise.allSettled(
_conversations.map(({ host, token, appId, conversationId }) =>
deleteConversation(host, token, appId, conversationId),
),
)
const failed = results.filter(r => r.status === 'rejected')
if (failed.length > 0) {
console.warn(
`[E2E teardown] ${failed.length} conversation deletion(s) failed (non-blocking):`,
failed.map(r => (r as PromiseRejectedResult).reason).join(', '),
)
}
else {
console.log(`[E2E teardown] All conversations cleaned up.`)
}
_conversations.length = 0
}
async function deleteConversation(
host: string,
token: string,
appId: string,
conversationId: string,
): Promise<void> {
const url = `${host.replace(/\/$/, '')}/openapi/v1/apps/${appId}/conversations/${conversationId}`
const res = await fetch(url, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(8_000),
})
// 404 is acceptable — conversation may have already been cleaned up
if (!res.ok && res.status !== 404) {
throw new Error(`DELETE ${url} → HTTP ${res.status}`)
}
}

View File

@ -1,373 +0,0 @@
/**
* E2E CLI runner helpers.
*
* Core primitive: run(argv, opts) → { stdout, stderr, exitCode }
*
* The binary is invoked via `bun bin/dev.js` so tests work without a prior
* `pnpm build`. Each test should use its own isolated configDir (created via
* withTempConfig) to prevent session state leaking between tests.
*/
import { Buffer } from 'node:buffer'
import { execSync, spawn } from 'node:child_process'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
/** Path to the dev entry point — no build required. */
export const BIN = resolve(__dirname, '../../../bin/dev.js')
/**
* Resolve the `bun` executable path.
* Priority: PATH → ~/.bun/bin/bun → /usr/local/bin/bun
*/
function resolveBun(): string {
const candidates = [
// Respect PATH first
'bun',
// Common install locations
`${process.env.HOME}/.bun/bin/bun`,
'/usr/local/bin/bun',
'/opt/homebrew/bin/bun',
]
for (const candidate of candidates) {
try {
execSync(`${candidate} --version`, { stdio: 'ignore', timeout: 3000 })
return candidate
}
catch { /* try next */ }
}
throw new Error(
'bun not found. Install it with: curl -fsSL https://bun.sh/install | bash',
)
}
export const BUN = resolveBun()
// ── Types ─────────────────────────────────────────────────────────────────
export type RunOptions = {
/**
* Override or extend the process environment.
* Values are merged on top of `process.env`.
*/
env?: Record<string, string>
/**
* Path to an isolated config directory.
* The CLI reads hosts.yml from this directory.
* Passed as DIFY_CONFIG_DIR env var.
*/
configDir?: string
/** Maximum time to wait for the process, in ms. Default: 30 000 */
timeout?: number
/** String to write to stdin, then close the pipe. */
stdin?: string
}
export type RunResult = {
stdout: string
stderr: string
exitCode: number
}
// ── Core runner ────────────────────────────────────────────────────────────
/**
* Execute `difyctl <argv>` and return the captured stdout, stderr and exit code.
*
* Environment notes:
* - CI=1 suppresses interactive prompts and spinners.
* - NO_COLOR=1 strips ANSI escape codes from output.
* - DIFY_CONFIG_DIR is set to opts.configDir when provided.
*/
export function run(argv: string[], opts: RunOptions = {}): Promise<RunResult> {
return new Promise((resolve, reject) => {
const env: Record<string, string> = {
...(process.env as Record<string, string>),
// Suppress interactive prompts in all E2E tests.
CI: '1',
NO_COLOR: '1',
// Point the CLI at the isolated config directory.
...(opts.configDir !== undefined ? { DIFY_CONFIG_DIR: opts.configDir } : {}),
...opts.env,
}
const proc = spawn(BUN, [BIN, ...argv], { env })
const timeoutMs = opts.timeout ?? 30_000
let timedOut = false
const timeoutId = setTimeout(() => {
timedOut = true
proc.kill('SIGINT')
setTimeout(() => proc.kill('SIGKILL'), 2000).unref?.()
}, timeoutMs)
timeoutId.unref?.()
let stdout = ''
let stderr = ''
proc.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8')
})
proc.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8')
})
if (opts.stdin !== undefined) {
proc.stdin.write(opts.stdin)
proc.stdin.end()
}
proc.on('close', (code: number | null) => {
clearTimeout(timeoutId)
resolve({ stdout, stderr, exitCode: code ?? (timedOut ? 124 : 1) })
})
proc.on('error', (err: Error) => {
clearTimeout(timeoutId)
reject(new Error(`Failed to spawn CLI process: ${err.message}`))
})
})
}
// ── Config directory helpers ───────────────────────────────────────────────
export type TempConfig = {
/** Path to the isolated config directory. */
configDir: string
/** Remove the directory and all its contents. */
cleanup: () => Promise<void>
}
/**
* Create a fresh temporary config directory for a single test.
* Always call cleanup() in afterEach to avoid leaking temp directories.
*/
export async function withTempConfig(): Promise<TempConfig> {
const configDir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-'))
return {
configDir,
cleanup: () => rm(configDir, { recursive: true, force: true }),
}
}
// ── Auth injection ─────────────────────────────────────────────────────────
export type AuthInjectionOptions = {
/** Staging server base URL (no trailing slash). */
host: string
/** Bearer token — dfoa_ for internal, dfoe_ for SSO. */
bearer: string
/** Primary workspace to write into the bundle. */
workspaceId: string
workspaceName: string
workspaceRole?: string
/**
* Server-side session UUID (OAuthAccessToken.id).
* When provided, written as `token_id` in hosts.yml so that
* `devices revoke` can correctly detect selfHit and clear local credentials.
*/
tokenId?: string
}
/**
* Write a pre-baked hosts.yml into configDir so tests can skip the real
* Device-Flow login. Auth-specific E2E tests (login/logout/status) use the
* real flow and should NOT call this function.
*/
export async function injectAuth(configDir: string, opts: AuthInjectionOptions): Promise<void> {
await mkdir(configDir, { recursive: true, mode: 0o700 })
const role = opts.workspaceRole ?? 'owner'
// Serialise to YAML manually to avoid a runtime dep on js-yaml in helpers.
const hostsYml = `${[
`current_host: ${opts.host}`,
`token_storage: file`,
`tokens:`,
` bearer: ${opts.bearer}`,
...(opts.tokenId !== undefined ? [`token_id: ${opts.tokenId}`] : []),
`workspace:`,
` id: ${opts.workspaceId}`,
` name: "${opts.workspaceName}"`,
` role: ${role}`,
`available_workspaces:`,
` - id: ${opts.workspaceId}`,
` name: "${opts.workspaceName}"`,
` role: ${role}`,
].join('\n')}\n`
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
}
// ── Process signal helpers ─────────────────────────────────────────────────
export type SpawnedProcess = {
/** Send SIGINT (Ctrl+C) to the process. */
interrupt: () => void
/** Wait for the process to exit and return the result. */
wait: () => Promise<RunResult>
}
/**
* Start `difyctl <argv>` in the background without waiting for it to finish.
* Useful for testing interrupt / timeout behaviour.
*/
export function spawn_background(argv: string[], opts: RunOptions = {}): SpawnedProcess {
const env: Record<string, string> = {
...(process.env as Record<string, string>),
CI: '1',
NO_COLOR: '1',
...(opts.configDir !== undefined ? { DIFY_CONFIG_DIR: opts.configDir } : {}),
...opts.env,
}
const proc = spawn(BUN, [BIN, ...argv], { env })
const timeoutMs = opts.timeout ?? 30_000
let timedOut = false
const timeoutId = setTimeout(() => {
timedOut = true
proc.kill('SIGINT')
setTimeout(() => proc.kill('SIGKILL'), 2000).unref?.()
}, timeoutMs)
timeoutId.unref?.()
let stdout = ''
let stderr = ''
proc.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8')
})
proc.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8')
})
return {
interrupt: () => { proc.kill('SIGINT') },
wait: () => new Promise((res) => {
proc.on('close', (code: number | null) => {
clearTimeout(timeoutId)
res({ stdout, stderr, exitCode: code ?? (timedOut ? 124 : 1) })
})
}),
}
}
// ── Auth fixture ───────────────────────────────────────────────────────────
export type AuthFixture = {
/** Path to the isolated config directory, pre-loaded with a valid session. */
configDir: string
/**
* Run `difyctl <argv>` using the fixture's config dir.
* Shorthand for `run(argv, { configDir, env })`.
*/
r: (argv: string[], extraEnv?: Record<string, string>) => Promise<RunResult>
/** Remove the temp config directory. Call in afterEach. */
cleanup: () => Promise<void>
}
/**
* Create an isolated config directory pre-loaded with a valid internal-user
* session. Designed for use with vitest's beforeEach / afterEach:
*
* @example
* let fx: AuthFixture
* beforeEach(async () => { fx = await withAuthFixture(E) })
* afterEach(async () => { await fx.cleanup() })
*
* it('...', async () => {
* const result = await fx.r(['get', 'app'])
* assertExitCode(result, 0)
* })
*/
export async function withAuthFixture(
E: { host: string, token: string, workspaceId: string, workspaceName: string },
): Promise<AuthFixture> {
const { configDir, cleanup } = await withTempConfig()
await injectAuth(configDir, {
host: E.host,
bearer: E.token,
workspaceId: E.workspaceId,
workspaceName: E.workspaceName,
})
return {
configDir,
r: (argv, extraEnv) => run(argv, { configDir, env: extraEnv }),
cleanup,
}
}
// ── On-demand disposable token ─────────────────────────────────────────────
/**
* Mint a fresh dfoa_ OAuth token on demand via the 3-step device flow API.
* Use this inside tests that need to revoke a real session without consuming
* the shared DIFY_E2E_TOKEN or the global-setup disposableToken.
*
* Requires DIFY_E2E_EMAIL and DIFY_E2E_PASSWORD to be set.
* Returns empty string if credentials are missing.
*
* Steps:
* 1. POST /console/api/login (Base64 password) → session cookie
* 2. POST /openapi/v1/oauth/device/code → device_code + user_code
* 3. POST /openapi/v1/oauth/device/approve → approved
* 4. POST /openapi/v1/oauth/device/token → dfoa_ token
*/
export async function mintFreshToken(
host: string,
email: string,
password: string,
): Promise<string> {
if (!email || !password)
return ''
const base = host.replace(/\/$/, '')
const sig = AbortSignal.timeout(15_000)
// Step 1 — console login
const passwordB64 = Buffer.from(password, 'utf8').toString('base64')
const loginRes = await fetch(`${base}/console/api/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password: passwordB64, remember_me: false }),
signal: AbortSignal.timeout(10_000),
})
if (!loginRes.ok)
return ''
const setCookieHeaders = loginRes.headers.getSetCookie?.() ?? []
const cookieString = setCookieHeaders.map(c => c.split(';')[0]).join('; ')
const csrfMatch = cookieString.match(/csrf_token=([^;]+)/)
const csrfToken = csrfMatch ? csrfMatch[1] : ''
// Step 2 — device code
const codeRes = await fetch(`${base}/openapi/v1/oauth/device/code`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: 'difyctl', device_label: 'e2e-fresh' }),
signal: sig,
})
if (!codeRes.ok)
return ''
const { device_code, user_code } = await codeRes.json() as { device_code: string, user_code: string }
// Step 3 — approve
const approveRes = await fetch(`${base}/openapi/v1/oauth/device/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Cookie': cookieString, 'X-CSRFToken': csrfToken },
body: JSON.stringify({ user_code }),
signal: AbortSignal.timeout(10_000),
})
if (!approveRes.ok)
return ''
// Step 4 — poll token
const tokenRes = await fetch(`${base}/openapi/v1/oauth/device/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_code, client_id: 'difyctl' }),
signal: AbortSignal.timeout(10_000),
})
if (!tokenRes.ok)
return ''
const body = await tokenRes.json() as { token?: string }
return body.token ?? ''
}

View File

@ -1,51 +0,0 @@
/**
* Retry helper for E2E tests running against a staging server.
*
* Staging environments can be flaky — occasional 5xx errors or slow cold
* starts are expected. Use `withRetry` to wrap assertions that may fail
* transiently without masking real failures.
*/
const DEFAULT_ATTEMPTS = 3
const DEFAULT_DELAY_MS = 1000
export type RetryOptions = {
/** Total number of attempts (first try + retries). Default: 3 */
attempts?: number
/** Delay between retries in ms. Default: 1000 */
delayMs?: number
/** Optional predicate — only retry when this returns true for the error. */
shouldRetry?: (err: unknown) => boolean
}
/**
* Execute `fn()` and retry on failure.
*
* @example
* const result = await withRetry(() => run(['get', 'app', '-o', 'json']))
*/
export async function withRetry<T>(fn: () => Promise<T>, opts: RetryOptions = {}): Promise<T> {
const total = opts.attempts ?? DEFAULT_ATTEMPTS
const delay = opts.delayMs ?? DEFAULT_DELAY_MS
const shouldRetry = opts.shouldRetry ?? (() => true)
let lastErr: unknown
for (let attempt = 1; attempt <= total; attempt++) {
try {
return await fn()
}
catch (err) {
lastErr = err
if (attempt === total || !shouldRetry(err))
break
console.warn(`[E2E retry] attempt ${attempt}/${total} failed — retrying in ${delay}ms`)
await sleep(delay)
}
}
throw lastErr
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}

View File

@ -1,9 +0,0 @@
import { describe, it } from 'vitest'
export function optionalDescribe(condition: boolean) {
return condition ? describe : describe.skip
}
export function optionalIt(condition: boolean) {
return condition ? it : it.skip
}

View File

@ -1,9 +0,0 @@
import type { E2ECapabilities } from '../setup/env.js'
declare module 'vitest' {
export type ProvidedContext = {
e2eCapabilities: E2ECapabilities
}
}
export { }

View File

@ -1,109 +0,0 @@
/**
* E2E environment configuration.
*
* All DIFY_E2E_* variables must be set before running E2E tests.
* In CI they are injected from GitHub Actions secrets.
* Locally, export them in your shell or use a .env.e2e file.
*
* Required:
* DIFY_E2E_HOST Staging server base URL (e.g. https://api.staging.dify.ai)
* DIFY_E2E_TOKEN Internal user bearer token (dfoa_ prefix)
* DIFY_E2E_WORKSPACE_ID Workspace ID for the test account
* DIFY_E2E_CHAT_APP_ID Echo-chat app — outputs "echo: {query}"
* DIFY_E2E_WORKFLOW_APP_ID Echo-workflow app — input x (required), outputs "echo: {x}"
*
* Optional (skip related tests when absent):
* DIFY_E2E_SSO_TOKEN External SSO bearer token (dfoe_ prefix)
* DIFY_E2E_HITL_APP_ID Workflow app with a Human-Input node
* DIFY_E2E_FILE_APP_ID Workflow app with a file input variable (doc)
*/
export type E2EEnv = {
/** Staging server base URL */
host: string
/** Internal user bearer token (dfoa_…) */
token: string
/** External SSO bearer token (dfoe_…) — may be empty */
ssoToken: string
/** Primary workspace ID */
workspaceId: string
/** Workspace name (informational) */
workspaceName: string
/** Chat app that echoes the query */
chatAppId: string
/** Workflow app that echoes input x */
workflowAppId: string
/** Workflow app with HITL node — empty when not configured */
hitlAppId: string
/** Workflow app with file input (doc variable) — empty when not configured */
fileAppId: string
/**
* Console account email — used by global-setup to mint a disposable token
* for logout tests via the device flow API. Optional: if absent, logout
* tests that need a real revoke are skipped.
*/
email: string
/** Console account password (plain-text; Base64-encoded before sending) */
password: string
}
export type E2ECapabilities = {
tokenValid: boolean
tokenId?: string
/**
* Per-suite dedicated tokens minted by global-setup via the device flow.
* Each destructive suite (logout, devices) gets its own fresh dfoa_ token so
* that revoking it never invalidates DIFY_E2E_TOKEN used by other suites.
* Empty string when DIFY_E2E_EMAIL/PASSWORD are not configured.
*/
logoutToken: string
devicesToken: string
}
let _cached: E2EEnv | undefined
/** Load and validate E2E environment variables. Throws if required vars are missing. */
export function loadE2EEnv(): E2EEnv {
if (_cached !== undefined)
return _cached
const required: Array<[keyof NodeJS.ProcessEnv, string]> = [
['DIFY_E2E_HOST', 'Staging server URL'],
['DIFY_E2E_TOKEN', 'Internal user bearer token'],
['DIFY_E2E_WORKSPACE_ID', 'Workspace ID'],
['DIFY_E2E_CHAT_APP_ID', 'Echo-chat app ID'],
['DIFY_E2E_WORKFLOW_APP_ID', 'Echo-workflow app ID'],
]
const missing = required.filter(([k]) => !process.env[k])
if (missing.length > 0) {
const list = missing.map(([k, desc]) => ` ${k} (${desc})`).join('\n')
throw new Error(
`E2E tests require the following environment variables to be set:\n${list}\n\n`
+ 'See test/e2e/setup/env.ts for documentation.',
)
}
_cached = {
host: process.env.DIFY_E2E_HOST!,
token: process.env.DIFY_E2E_TOKEN!,
ssoToken: process.env.DIFY_E2E_SSO_TOKEN ?? '',
workspaceId: process.env.DIFY_E2E_WORKSPACE_ID!,
workspaceName: process.env.DIFY_E2E_WORKSPACE_NAME ?? 'E2E Workspace',
chatAppId: process.env.DIFY_E2E_CHAT_APP_ID!,
workflowAppId: process.env.DIFY_E2E_WORKFLOW_APP_ID!,
hitlAppId: process.env.DIFY_E2E_HITL_APP_ID ?? '',
fileAppId: process.env.DIFY_E2E_FILE_APP_ID ?? '',
email: process.env.DIFY_E2E_EMAIL ?? '',
password: process.env.DIFY_E2E_PASSWORD ?? '',
}
return _cached
}
/**
* Skip a test when an optional app fixture is not configured.
* Usage: skipUnless(E.hitlAppId, 'DIFY_E2E_HITL_APP_ID')
*/
export function isE2ELocalMode(): boolean {
return process.env.DIFY_E2E_MODE === 'local'
}

View File

@ -1,169 +0,0 @@
/**
* Vitest global setup — runs once before all E2E suites.
*
* Responsibilities:
* 1. Validate required environment variables are present.
* 2. Confirm the staging server is reachable AND the shared token is valid —
* GET /openapi/v1/account/sessions (HTTP 200 = valid, else abort).
* 3. Resolve the current session's token_id via the prefix field.
* 4. Mint per-suite dedicated tokens for suites that revoke sessions:
* - logoutToken → auth/logout.e2e.ts
* - devicesToken → auth/devices.e2e.ts
* Each suite consumes only its own token, so DIFY_E2E_TOKEN remains
* valid for all non-destructive suites throughout the run.
*
* If the health-check fails the entire test run is aborted early.
*/
import type { TestProject } from 'vitest/node'
import type { E2ECapabilities } from './env.js'
import { Buffer } from 'node:buffer'
import { loadE2EEnv } from './env.js'
export async function setup(project: TestProject): Promise<void> {
if (process.env.DIFY_E2E_MODE === 'local')
return
const E = loadE2EEnv()
const base = E.host.replace(/\/$/, '')
// ── 1. Validate main token ─────────────────────────────────────────────
const sessionsUrl = `${base}/openapi/v1/account/sessions?page=1&limit=100`
let res: Response
try {
res = await fetch(sessionsUrl, {
headers: { Authorization: `Bearer ${E.token}` },
signal: AbortSignal.timeout(10_000),
})
}
catch (err) {
throw new Error(
`[E2E global-setup] Cannot reach staging server at ${sessionsUrl}.\n`
+ `Check DIFY_E2E_HOST and network connectivity.\n${String(err)}`,
)
}
if (!res.ok) {
throw new Error(
`[E2E global-setup] Token is invalid or expired (HTTP ${res.status}).\n`
+ `Update DIFY_E2E_TOKEN and retry.\nURL: ${sessionsUrl}`,
)
}
console.log(`[E2E] Staging server is healthy and token is valid at ${E.host}`)
// ── 2. Resolve token_id ────────────────────────────────────────────────
const body = await res.json() as { data: Array<{ id: string, prefix: string }> }
const match = body.data.find(s => s.prefix !== '' && E.token.startsWith(s.prefix))
// ── 3. Mint per-suite dedicated tokens ────────────────────────────────
let logoutToken = ''
let devicesToken = ''
if (E.email && E.password) {
const mint = (label: string) => mintToken(base, E.email, E.password, label)
const [lt, dt] = await Promise.allSettled([
mint('e2e-logout-suite'),
mint('e2e-devices-suite'),
])
if (lt.status === 'fulfilled') {
logoutToken = lt.value
console.log(`[E2E] logoutToken minted: ${logoutToken.slice(0, 20)}`)
}
else {
console.warn(`[E2E global-setup] Failed to mint logoutToken: ${lt.reason}`)
}
if (dt.status === 'fulfilled') {
devicesToken = dt.value
console.log(`[E2E] devicesToken minted: ${devicesToken.slice(0, 20)}`)
}
else {
console.warn(`[E2E global-setup] Failed to mint devicesToken: ${dt.reason}`)
}
}
else {
console.warn('[E2E global-setup] DIFY_E2E_EMAIL/PASSWORD not set — per-suite tokens not minted; destructive tests may skip')
}
const capabilities: E2ECapabilities = {
tokenValid: true,
tokenId: match?.id,
logoutToken,
devicesToken,
}
project.provide('e2eCapabilities', capabilities)
}
export { teardown } from './global-teardown.js'
// ── Device flow token minting ──────────────────────────────────────────────
/**
* Mint a fresh dfoa_ OAuth token via the 3-step device flow:
* 1. POST /openapi/v1/oauth/device/code → device_code + user_code
* 2. POST /console/api/login → session cookie + CSRF
* POST /openapi/v1/oauth/device/approve (with cookie)
* 3. POST /openapi/v1/oauth/device/token → dfoa_ bearer token
*/
async function mintToken(base: string, email: string, password: string, label: string): Promise<string> {
// Step 1 — request device code
const codeRes = await fetch(`${base}/openapi/v1/oauth/device/code`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: 'difyctl', device_label: label }),
signal: AbortSignal.timeout(15_000),
})
if (!codeRes.ok)
throw new Error(`device/code failed: HTTP ${codeRes.status}`)
const { device_code, user_code } = await codeRes.json() as { device_code: string, user_code: string }
// Step 2a — console login → session cookie + CSRF token
const passwordB64 = Buffer.from(password, 'utf8').toString('base64')
const loginRes = await fetch(`${base}/console/api/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password: passwordB64, remember_me: false }),
signal: AbortSignal.timeout(10_000),
})
if (!loginRes.ok)
throw new Error(`console/api/login failed: HTTP ${loginRes.status}`)
const setCookieHeaders = loginRes.headers.getSetCookie?.() ?? []
const cookieString = setCookieHeaders.map(c => c.split(';')[0]).join('; ')
const csrfMatch = cookieString.match(/csrf_token=([^;]+)/)
const csrfToken = csrfMatch ? csrfMatch[1] : ''
// Step 2b — approve the device code
const approveRes = await fetch(`${base}/openapi/v1/oauth/device/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cookie': cookieString,
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({ user_code }),
signal: AbortSignal.timeout(10_000),
})
if (!approveRes.ok)
throw new Error(`device/approve failed: HTTP ${approveRes.status}`)
// Step 3 — exchange device code for bearer token
const tokenRes = await fetch(`${base}/openapi/v1/oauth/device/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_code, client_id: 'difyctl' }),
signal: AbortSignal.timeout(10_000),
})
if (!tokenRes.ok)
throw new Error(`device/token failed: HTTP ${tokenRes.status}`)
const tokenBody = await tokenRes.json() as { token?: string, error?: string }
if (!tokenBody.token)
throw new Error(`device/token response missing token: ${JSON.stringify(tokenBody)}`)
return tokenBody.token
}

View File

@ -1,15 +0,0 @@
/**
* Vitest global teardown — runs once after all E2E suites complete.
*
* Responsibilities:
* 1. Delete all conversations created on the staging server during the run
* (collected via registerConversation() in test suites).
*
* Deletion is best-effort — failures are logged but do not fail the run.
*/
import { cleanupRegisteredConversations } from '../helpers/cleanup-registry.js'
export async function teardown(): Promise<void> {
await cleanupRegisteredConversations()
}

View File

@ -1,135 +0,0 @@
/**
* E2E: difyctl auth devices — multi-device session management
*
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Auth/Multi-device Session Management (21 cases)
*/
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
import { assertExitCode, assertJson } from '../../helpers/assert.js'
import { injectAuth, mintFreshToken, run, withTempConfig } from '../../helpers/cli.js'
import { optionalIt } from '../../helpers/skip.js'
import { loadE2EEnv } from '../../setup/env.js'
const E = loadE2EEnv()
const caps = inject('e2eCapabilities')
const tokenValid = caps.tokenValid
const tokenId = caps.tokenId
describe('E2E / difyctl auth devices', () => {
let configDir: string
let cleanup: () => Promise<void>
beforeEach(async () => {
const tmp = await withTempConfig()
configDir = tmp.configDir
cleanup = tmp.cleanup
await injectAuth(configDir, {
host: E.host,
bearer: E.token,
workspaceId: E.workspaceId,
workspaceName: E.workspaceName,
tokenId,
})
})
afterEach(async () => {
await cleanup()
})
function r(argv: string[]) {
return run(argv, { configDir })
}
// ── devices list ─────────────────────────────────────────────────────────────
const itSessions = optionalIt(tokenValid)
itSessions('[P0] logged-in user can view the devices list', async () => {
// Spec: logged-in user can view the devices list
const result = await r(['auth', 'devices', 'list'])
assertExitCode(result, 0)
expect(result.stdout.length).toBeGreaterThan(0)
})
itSessions('[P0] devices list displays device IDs', async () => {
// Spec: devices list displays device IDs
const result = await r(['auth', 'devices', 'list'])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/tok-|id|device/i)
})
itSessions('[P0] devices list supports JSON output and returns valid JSON', async () => {
// Spec: devices list supports JSON output
const result = await r(['auth', 'devices', 'list', '--json'])
assertExitCode(result, 0)
const parsed = assertJson<{ data: unknown[], total: number }>(result)
expect(parsed).toHaveProperty('data')
expect(Array.isArray(parsed.data)).toBe(true)
})
itSessions('[P1] devices list JSON schema is stable (contains data and total fields)', async () => {
// Spec: devices list JSON schema is stable
const result = await r(['auth', 'devices', 'list', '--json'])
assertExitCode(result, 0)
const parsed = assertJson<{ data: unknown[], total: number, page: number, limit: number }>(result)
expect(parsed).toHaveProperty('total')
expect(parsed).toHaveProperty('page')
expect(parsed).toHaveProperty('limit')
})
it('[P0] unauthenticated devices list returns auth error (exit code 4)', async () => {
// Spec: unauthenticated devices list returns auth error + exit code 4
const unauthTmp = await withTempConfig()
try {
const result = await run(['auth', 'devices', 'list'], { configDir: unauthTmp.configDir })
assertExitCode(result, 4)
expect(result.stderr).toMatch(/not.?logged.?in|auth.?login/i)
}
finally {
await unauthTmp.cleanup()
}
})
// ── devices revoke ───────────────────────────────────────────────────────────
itSessions('[P0] revoking a specified device succeeds (exit code 0)', async () => {
// Spec: revoking a specified device succeeds
// Mint a fresh token on demand so this test only revokes its own session,
// never the shared E.token or the global-setup disposableToken.
const freshToken = await mintFreshToken(E.host, E.email, E.password)
if (!freshToken) {
// Credentials not configured — skip rather than risk revoking the main session.
return
}
// Inject the fresh token into a dedicated config dir
const revokeTmp = await withTempConfig()
try {
await injectAuth(revokeTmp.configDir, {
host: E.host,
bearer: freshToken,
workspaceId: E.workspaceId,
workspaceName: E.workspaceName,
})
const revokeR = (argv: string[]) => run(argv, { configDir: revokeTmp.configDir })
// List sessions authenticated as the fresh token
const listResult = await revokeR(['auth', 'devices', 'list', '--json'])
assertExitCode(listResult, 0)
const { data } = assertJson<{ data: Array<{ id: string, prefix: string }> }>(listResult)
// Find the entry whose prefix matches the fresh token
const entry = data.find(d => d.prefix && freshToken.startsWith(d.prefix))
if (!entry) {
// Fresh session not found — may have been filtered; skip gracefully.
return
}
const revokeResult = await revokeR(['auth', 'devices', 'revoke', entry.id, '--yes'])
assertExitCode(revokeResult, 0)
}
finally {
await revokeTmp.cleanup()
}
})
})

View File

@ -1,183 +0,0 @@
/**
* E2E: difyctl auth logout — Logout
*
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Auth/Logout (18 cases)
*/
import { access } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
import { assertExitCode } from '../../helpers/assert.js'
import { injectAuth, run, withTempConfig } from '../../helpers/cli.js'
import { loadE2EEnv } from '../../setup/env.js'
const E = loadE2EEnv()
const caps = inject('e2eCapabilities')
describe('E2E / difyctl auth logout', () => {
let configDir: string
let cleanup: () => Promise<void>
beforeEach(async () => {
const { configDir: dir, cleanup: cl } = await withTempConfig()
configDir = dir
cleanup = cl
})
afterEach(async () => {
await cleanup()
})
function r(argv: string[]) {
return run(argv, { configDir })
}
/**
* Inject the dedicated per-suite logoutToken so that auth logout
* calls DELETE /account/sessions/self on a disposable session and
* never revokes the shared DIFY_E2E_TOKEN used by other suites.
*/
async function withAuth() {
const token = caps.logoutToken || E.token
await injectAuth(configDir, {
host: E.host,
bearer: token,
workspaceId: E.workspaceId,
workspaceName: E.workspaceName,
})
}
async function hostsFileExists(): Promise<boolean> {
try {
await access(join(configDir, 'hosts.yml'))
return true
}
catch { return false }
}
// ── Basic logout ────────────────────────────────────────────────────────────
it('[P0] logged-in user can logout successfully — stdout contains success message', async () => {
// Spec: logged-in user can logout successfully
await withAuth()
const result = await r(['auth', 'logout'])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/logged out/i)
})
it('[P0] local hosts.yml is deleted after logout', async () => {
// Spec: local token deleted after logout
await withAuth()
expect(await hostsFileExists()).toBe(true)
await r(['auth', 'logout'])
expect(await hostsFileExists()).toBe(false)
})
it('[P0] auth status returns "Not logged in" after logout', async () => {
// Spec: auth status returns not-logged-in after logout
await withAuth()
await r(['auth', 'logout'])
const statusResult = await r(['auth', 'status'])
expect(statusResult.exitCode).toBe(4)
expect(statusResult.stdout).toMatch(/not logged in/i)
})
it('[P1] auth status exit code is 4 after logout', async () => {
// Spec: auth status exit code is 4 after logout
await withAuth()
await r(['auth', 'logout'])
const statusResult = await r(['auth', 'status'])
expect(statusResult.exitCode).toBe(4)
})
it('[P0] logout calls the revoke session endpoint (or best-effort local credential clear)', async () => {
// Spec: logout calls the revoke session endpoint + logout returns success when revoke succeeds
// Uses disposableToken so the shared DIFY_E2E_TOKEN is not revoked.
await withAuth()
const result = await r(['auth', 'logout'])
// Local token must be cleared regardless of whether server revoke succeeds
assertExitCode(result, 0)
expect(await hostsFileExists()).toBe(false)
})
it('[P0] local credentials are cleared even when server revoke fails (best-effort)', async () => {
// Spec: local credentials cleared even when server revoke fails
// Inject an invalid token → server rejects revoke, but local state must still be cleared
await injectAuth(configDir, {
host: E.host,
bearer: 'dfoa_invalid_will_fail_revoke',
workspaceId: E.workspaceId,
workspaceName: E.workspaceName,
})
const result = await r(['auth', 'logout'])
// exit 0 (best-effort); local file is cleared
assertExitCode(result, 0)
expect(await hostsFileExists()).toBe(false)
})
// ── Unauthenticated (idempotent) ─────────────────────────────────────────────
it('[P0] logout without a session returns not_logged_in error (exit code 4)', async () => {
// Spec: logout without a session is idempotent
// Actual behaviour: CLI returns not_logged_in (exit 4) when no token is present
const result = await r(['auth', 'logout'])
assertExitCode(result, 4)
expect(result.stderr).toMatch(/not.?logged.?in/i)
})
// ── External SSO logout ─────────────────────────────────────────────────────
it('[P0] external SSO user logout works correctly — local token cleared', async () => {
// Spec: external SSO user logout works correctly
const { writeFile } = await import('node:fs/promises')
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
`tokens:`,
` bearer: dfoe_sso_test_token`,
`external_subject:`,
` email: sso@example.com`,
` issuer: https://issuer.example.com`,
].join('\n')}\n`
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await r(['auth', 'logout'])
assertExitCode(result, 0)
expect(await hostsFileExists()).toBe(false)
})
// ── Network error scenario ───────────────────────────────────────────────────
it('[P0] local token is cleared even when logout encounters a network error', async () => {
// Spec: local credentials cleared even when network is unavailable
// Use an unreachable host to simulate network failure
const { writeFile, mkdir } = await import('node:fs/promises')
await mkdir(configDir, { recursive: true })
const hostsYml = `${[
`current_host: http://unreachable-host-xyz.invalid`,
`token_storage: file`,
`tokens:`,
` bearer: dfoa_test_network_error`,
`workspace:`,
` id: ws-1`,
` name: Test`,
` role: owner`,
].join('\n')}\n`
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await run(['auth', 'logout'], { configDir, timeout: 10_000 })
// Local token is cleared even if network request fails
assertExitCode(result, 0)
expect(await hostsFileExists()).toBe(false)
})
// ── Post-logout operations ───────────────────────────────────────────────────
it('[P1] run app returns auth error (exit code 4) after logout', async () => {
// Spec: run app returns auth error after logout
// Use disposableToken so the shared DIFY_E2E_TOKEN is not revoked.
await withAuth()
await r(['auth', 'logout'])
const result = await r(['run', 'app', E.chatAppId, 'test'])
expect(result.exitCode).toBe(4)
})
})

View File

@ -1,180 +0,0 @@
/**
* E2E: difyctl auth status — Auth Status
*
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Auth/Auth Status (12 cases)
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { assertExitCode, assertNoAnsi } from '../../helpers/assert.js'
import { injectAuth, run, withTempConfig } from '../../helpers/cli.js'
import { loadE2EEnv } from '../../setup/env.js'
const E = loadE2EEnv()
describe('E2E / difyctl auth status', () => {
let configDir: string
let cleanup: () => Promise<void>
beforeEach(async () => {
const { configDir: dir, cleanup: cl } = await withTempConfig()
configDir = dir
cleanup = cl
})
afterEach(async () => {
await cleanup()
})
function r(argv: string[], extraEnv?: Record<string, string>) {
return run(argv, { configDir, env: extraEnv })
}
async function withAuth() {
// Write a complete bundle including account fields so --json output includes account
const { writeFile, mkdir } = await import('node:fs/promises')
const { join } = await import('node:path')
await mkdir(configDir, { recursive: true, mode: 0o700 })
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
`tokens:`,
` bearer: ${E.token}`,
`account:`,
` id: acct-e2e`,
` email: e2e@example.com`,
` name: E2E User`,
`workspace:`,
` id: ${E.workspaceId}`,
` name: "${E.workspaceName}"`,
` role: owner`,
`available_workspaces:`,
` - id: ${E.workspaceId}`,
` name: "${E.workspaceName}"`,
` role: owner`,
].join('\n')}\n`
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
}
async function withSSOAuth() {
await injectAuth(configDir, {
host: E.host,
bearer: E.ssoToken || 'dfoe_test',
workspaceId: '',
workspaceName: '',
})
// Overwrite to add external_subject field
const { writeFile } = await import('node:fs/promises')
const { join } = await import('node:path')
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
`tokens:`,
` bearer: ${E.ssoToken || 'dfoe_test'}`,
`external_subject:`,
` email: sso@example.com`,
` issuer: https://issuer.example.com`,
].join('\n')}\n`
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
}
// ── Basic status display ─────────────────────────────────────────────────────
it('[P0] internal user auth status displays host, email, and workspace info', async () => {
// Spec: internal user auth status displays host information
await withAuth()
const result = await r(['auth', 'status'])
assertExitCode(result, 0)
expect(result.stdout).toContain(E.host.replace(/^https?:\/\//, ''))
expect(result.stdout).toContain(E.workspaceName)
})
it('[P0] auth status --json outputs a valid JSON schema', async () => {
// Spec: auth status --json output is a parseable schema
await withAuth()
const result = await r(['auth', 'status', '--json'])
assertExitCode(result, 0)
const parsed = JSON.parse(result.stdout) as Record<string, unknown>
expect(parsed).toHaveProperty('logged_in', true)
expect(parsed).toHaveProperty('host')
expect(parsed).toHaveProperty('account')
})
it('[P1] auth status -v displays workspace role and storage info', async () => {
// Spec: auth status -v displays workspace role
await withAuth()
const result = await r(['auth', 'status', '-v'])
assertExitCode(result, 0)
expect(result.stdout).toContain('owner')
expect(result.stdout).toMatch(/file|keychain/)
})
// ── Unauthenticated scenario ─────────────────────────────────────────────────
it('[P0] unauthenticated auth status returns "Not logged in" — exit code 4', async () => {
// Spec: unauthenticated auth status returns error + exit code 4
// configDir is empty (no hosts.yml)
const result = await r(['auth', 'status'])
assertExitCode(result, 4)
expect(result.stdout).toMatch(/not logged in/i)
})
// ── External SSO user ────────────────────────────────────────────────────────
it('[P0] external SSO user auth status does not display workspace row', async () => {
// Spec: external SSO user auth status does not show workspace
await withSSOAuth()
const result = await r(['auth', 'status'])
assertExitCode(result, 0)
expect(result.stdout).not.toMatch(/workspace/i)
})
it('[P0] external SSO user auth status displays issuer URL', async () => {
// Spec: external SSO user auth status displays issuer URL
await withSSOAuth()
const result = await r(['auth', 'status'])
assertExitCode(result, 0)
expect(result.stdout).toContain('issuer.example.com')
})
it('[P0] external SSO user auth status displays External SSO session info', async () => {
// Spec: external SSO user auth status displays External SSO Session info
await withSSOAuth()
const result = await r(['auth', 'status'])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/SSO|apps:run/i)
})
// ── Error scenarios ──────────────────────────────────────────────────────────
it('[P0] auth status returns auth error when token is expired (401)', async () => {
// Spec: auth status returns auth error after token expires
// Inject a syntactically valid but actually expired token
await injectAuth(configDir, {
host: E.host,
bearer: 'dfoa_invalid_expired_token_xyz',
workspaceId: E.workspaceId,
workspaceName: E.workspaceName,
})
// auth status reads only the local hosts.yml (no network); status is shown as long as a token exists.
// Real token-expiry detection happens when commands like get app / run app are executed.
const result = await r(['auth', 'status'])
// A token present → show status without a 401 (status makes no network request)
assertExitCode(result, 0)
})
it('[P1] auth status outputs JSON error envelope in JSON mode', async () => {
// Spec: auth status outputs JSON error in JSON mode
const result = await r(['auth', 'status', '--json'])
// When not logged in, --json mode should output JSON rather than plain text
expect(result.exitCode).toBe(4)
// stdout should contain JSON (not-logged-in state)
const parsed = JSON.parse(result.stdout) as { logged_in: boolean }
expect(parsed.logged_in).toBe(false)
})
it('[P0] auth status output contains no ANSI colour (non-TTY)', async () => {
await withAuth()
const result = await r(['auth', 'status'])
assertExitCode(result, 0)
assertNoAnsi(result.stdout, 'stdout')
})
})

View File

@ -1,186 +0,0 @@
/**
* E2E: difyctl auth use — Workspace switching
*
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Auth/Workspace Switching (22 cases)
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { assertExitCode } from '../../helpers/assert.js'
import { run, withTempConfig } from '../../helpers/cli.js'
import { loadE2EEnv } from '../../setup/env.js'
const E = loadE2EEnv()
// Secondary workspace used in tests — injected into available_workspaces
const WS2_ID = 'ws-e2e-secondary-0000-000000000002'
const WS2_NAME = 'Secondary Workspace'
describe('E2E / difyctl auth use', () => {
let configDir: string
let cleanup: () => Promise<void>
beforeEach(async () => {
const tmp = await withTempConfig()
configDir = tmp.configDir
cleanup = tmp.cleanup
})
afterEach(async () => {
await cleanup()
})
function r(argv: string[]) {
return run(argv, { configDir })
}
/** Inject a bundle with two workspaces. */
async function withTwoWorkspaces() {
await mkdir(configDir, { recursive: true })
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
`tokens:`,
` bearer: ${E.token}`,
`workspace:`,
` id: ${E.workspaceId}`,
` name: "${E.workspaceName}"`,
` role: owner`,
`available_workspaces:`,
` - id: ${E.workspaceId}`,
` name: "${E.workspaceName}"`,
` role: owner`,
` - id: ${WS2_ID}`,
` name: "${WS2_NAME}"`,
` role: normal`,
].join('\n')}\n`
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
}
async function withSSOAuth() {
await mkdir(configDir, { recursive: true })
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
`tokens:`,
` bearer: dfoe_sso_test`,
`external_subject:`,
` email: sso@example.com`,
` issuer: https://issuer.example.com`,
].join('\n')}\n`
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
}
// ── Normal workspace switch ──────────────────────────────────────────────────
it('[P0] internal user can switch to a specified workspace', async () => {
// Spec: internal user can switch to a specified workspace
await withTwoWorkspaces()
const result = await r(['auth', 'use', WS2_ID])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/switched|workspace/i)
expect(result.stdout).toContain(WS2_NAME)
})
it('[P0] auth status shows the new workspace after auth use', async () => {
// Spec: auth status shows new workspace after auth use
await withTwoWorkspaces()
await r(['auth', 'use', WS2_ID])
const status = await r(['auth', 'status'])
assertExitCode(status, 0)
expect(status.stdout).toContain(WS2_NAME)
})
it('[P0] auth use updates current_workspace_id (hosts.yml is updated)', async () => {
// Spec: auth use updates current_workspace_id
await withTwoWorkspaces()
await r(['auth', 'use', WS2_ID])
const { readFile } = await import('node:fs/promises')
const hostsContent = await readFile(join(configDir, 'hosts.yml'), 'utf8')
expect(hostsContent).toContain(WS2_ID)
})
it('[P1] switching to the same workspace repeatedly is idempotent', async () => {
// Spec: switching to the same workspace is idempotent
await withTwoWorkspaces()
const r1 = await r(['auth', 'use', E.workspaceId])
assertExitCode(r1, 0)
const r2 = await r(['auth', 'use', E.workspaceId])
assertExitCode(r2, 0)
})
it('[P1] current workspace is persisted after auth use', async () => {
// Spec: current workspace is persisted after auth use
await withTwoWorkspaces()
await r(['auth', 'use', WS2_ID])
// Read hosts.yml directly to verify the workspace id was written
const { readFile } = await import('node:fs/promises')
const { join } = await import('node:path')
const hostsContent = await readFile(join(configDir, 'hosts.yml'), 'utf8')
expect(hostsContent).toContain(WS2_ID)
})
// ── Error scenarios ──────────────────────────────────────────────────────────
it('[P0] switching to a non-existent workspace returns an error', async () => {
// Spec: switching to a non-existent workspace returns an error
await withTwoWorkspaces()
const result = await r(['auth', 'use', 'ws-does-not-exist-xyz'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/not found|workspace/i)
})
it('[P0] current_workspace_id is unchanged when workspace switch fails', async () => {
// Spec: current_workspace_id is unchanged when workspace switch fails
await withTwoWorkspaces()
await r(['auth', 'use', 'ws-does-not-exist-xyz'])
// Read hosts.yml directly; the original workspace id should still be present
const { readFile } = await import('node:fs/promises')
const { join } = await import('node:path')
const hostsContent = await readFile(join(configDir, 'hosts.yml'), 'utf8')
expect(hostsContent).toContain(E.workspaceId)
})
it('[P0] unauthenticated auth use returns auth error (exit code 4)', async () => {
// Spec: unauthenticated auth use returns auth error + exit code 4
const result = await r(['auth', 'use', E.workspaceId])
assertExitCode(result, 4)
expect(result.stderr).toMatch(/not.?logged.?in|auth.?login/i)
})
it('[P0] missing workspace argument returns a usage error', async () => {
// Spec: missing workspace argument returns a usage error
await withTwoWorkspaces()
const result = await r(['auth', 'use'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/missing required argument|workspace/i)
})
// ── External SSO user ────────────────────────────────────────────────────────
it('[P0] external SSO user is rejected when executing auth use', async () => {
// Spec: external SSO user is rejected when executing auth use
await withSSOAuth()
const result = await r(['auth', 'use', 'any-ws-id'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/external SSO|workspace/i)
})
it('[P1] external SSO user auth use exit code is 1 or 2', async () => {
// Spec: external SSO user auth use exit code is 1
await withSSOAuth()
const result = await r(['auth', 'use', 'any-ws-id'])
expect([1, 2]).toContain(result.exitCode)
})
// ── JSON mode ────────────────────────────────────────────────────────────────
it('[P1] stderr contains an error description when workspace does not exist', async () => {
// Spec: non-existent workspace returns an error
// Note: auth use does not support the -o flag; errors are reported via stderr text
await withTwoWorkspaces()
const result = await r(['auth', 'use', 'ws-nonexistent-xyz'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/not.?found|workspace/i)
})
})

View File

@ -1,175 +0,0 @@
/**
* E2E: difyctl auth whoami + external SSO session behaviour
*
* Test cases sourced from: Dify CLI Enhanced spec
* - Dify CLI/Auth/External SSO Login (19 cases, testable subset)
*
* Note: interactive login (Device Flow browser) and Headless auth require a real browser;
* E2E layer bypasses Device Flow via injectAuth, focusing on session state and CLI behaviour.
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { assertExitCode } from '../../helpers/assert.js'
import { run, withTempConfig } from '../../helpers/cli.js'
import { optionalIt } from '../../helpers/skip.js'
import { loadE2EEnv } from '../../setup/env.js'
const E = loadE2EEnv()
describe('E2E / difyctl auth whoami + SSO session', () => {
let configDir: string
let cleanup: () => Promise<void>
beforeEach(async () => {
const tmp = await withTempConfig()
configDir = tmp.configDir
cleanup = tmp.cleanup
})
afterEach(async () => {
await cleanup()
})
function r(argv: string[]) {
return run(argv, { configDir })
}
async function withInternalAuth() {
await mkdir(configDir, { recursive: true, mode: 0o700 })
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
`tokens:`,
` bearer: ${E.token}`,
`account:`,
` id: acct-e2e`,
` email: e2e-user@example.com`,
` name: E2E User`,
`workspace:`,
` id: ${E.workspaceId}`,
` name: "${E.workspaceName}"`,
` role: owner`,
`available_workspaces:`,
` - id: ${E.workspaceId}`,
` name: "${E.workspaceName}"`,
` role: owner`,
].join('\n')}\n`
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
}
async function withSSOAuth(issuer = 'https://idp.example.com') {
await mkdir(configDir, { recursive: true })
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
`tokens:`,
` bearer: dfoe_sso_test_token`,
`external_subject:`,
` email: sso-user@example.com`,
` issuer: ${issuer}`,
].join('\n')}\n`
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
}
// ── auth whoami — internal user ──────────────────────────────────────────────
it('[P0] internal user auth whoami outputs email', async () => {
await withInternalAuth()
const result = await r(['auth', 'whoami'])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/@/)
})
it('[P0] auth whoami --json outputs valid JSON containing email', async () => {
await withInternalAuth()
const result = await r(['auth', 'whoami', '--json'])
assertExitCode(result, 0)
const parsed = JSON.parse(result.stdout) as { email: string }
expect(parsed).toHaveProperty('email')
expect(parsed.email).toMatch(/@/)
})
it('[P0] unauthenticated auth whoami returns auth error (exit code 4)', async () => {
const result = await r(['auth', 'whoami'])
assertExitCode(result, 4)
})
// ── External SSO user behaviour ──────────────────────────────────────────────
it('[P0] external SSO user auth status displays apps:run-only restriction', async () => {
// Spec: auth status displays apps:run-only restriction
await withSSOAuth()
const result = await r(['auth', 'status'])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/apps:run|SSO/i)
})
it('[P0] external SSO user auth status does not display workspace info', async () => {
// Spec: auth status does not display workspace information
await withSSOAuth()
const result = await r(['auth', 'status'])
assertExitCode(result, 0)
// SSO users have no workspace
expect(result.stdout).not.toMatch(/^ {2}Workspace:/m)
})
it('[P0] external SSO user auth status displays issuer URL', async () => {
// Spec: auth status displays External SSO Session + issuer URL
await withSSOAuth('https://idp.enterprise.com')
const result = await r(['auth', 'status'])
assertExitCode(result, 0)
expect(result.stdout).toContain('idp.enterprise.com')
})
it('[P0] external user gets an error executing auth use (external SSO subjects have no workspaces)', async () => {
// Spec: external user gets an error when executing auth use
await withSSOAuth()
const result = await r(['auth', 'use', 'any-ws-id'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/external SSO|workspace/i)
})
it('[P0] external user get workspace returns empty list or insufficient_scope', async () => {
// Spec: external user get workspace returns an empty list
await withSSOAuth()
const result = await r(['get', 'workspace'])
// SSO token has no workspace scope
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
await withSSOAuth()
const result = await r(['get', 'app'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/insufficient|scope|workspace|SSO/i)
})
it('[P0] external user whoami outputs SSO email', async () => {
await withSSOAuth()
const result = await r(['auth', 'whoami'])
assertExitCode(result, 0)
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 mkdir(configDir, { recursive: true })
const hostsYml = `${[
`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`
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await r(['run', 'app', E.chatAppId, 'hello'])
assertExitCode(result, 0)
expect(result.stdout.length).toBeGreaterThan(0)
})
})

View File

@ -1,299 +0,0 @@
/**
* E2E: difyctl config — configuration management
*
* Test cases sourced from: Dify CLI Enhanced spec
* - Dify CLI/Config/Initialization & Default Paths (26 cases, testable subset)
* - Dify CLI/Config/Environment Variable Override Priority (26 cases, testable subset)
*
* Covers sub-commands: config path / config get / config set / config unset / config view
* All cases run purely locally — no real Dify server required.
*/
import { access, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { assertExitCode, assertNoAnsi } from '../../helpers/assert.js'
import { run, withTempConfig } from '../../helpers/cli.js'
describe('E2E / difyctl config', () => {
let configDir: string
let cleanup: () => Promise<void>
beforeEach(async () => {
const tmp = await withTempConfig()
configDir = tmp.configDir
cleanup = tmp.cleanup
})
afterEach(async () => {
await cleanup()
})
function r(argv: string[], extraEnv?: Record<string, string>) {
return run(argv, { configDir, env: extraEnv })
}
// ── config path ──────────────────────────────────────────────────────────────
it('[P0] config path returns the correct absolute path to config.yml', async () => {
// Spec: default config path is correct
const result = await r(['config', 'path'])
assertExitCode(result, 0)
expect(result.stdout.trim()).toBe(join(configDir, 'config.yml'))
})
it('[P0] config path output ends with a newline', async () => {
const result = await r(['config', 'path'])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/config\.yml\n$/)
})
// ── config set / get ─────────────────────────────────────────────────────────
it('[P0] config set defaults.format writes successfully — stdout contains key=value', async () => {
const result = await r(['config', 'set', 'defaults.format', 'json'])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/defaults\.format/)
})
it('[P0] config get reads the previously written defaults.format', async () => {
await r(['config', 'set', 'defaults.format', 'json'])
const result = await r(['config', 'get', 'defaults.format'])
assertExitCode(result, 0)
expect(result.stdout.trim()).toBe('json')
})
it('[P0] config set defaults.limit writes and reads back correctly', async () => {
await r(['config', 'set', 'defaults.limit', '50'])
const result = await r(['config', 'get', 'defaults.limit'])
assertExitCode(result, 0)
expect(result.stdout.trim()).toBe('50')
})
it('[P0] config set state.current_app writes and reads back correctly', async () => {
const appId = 'app-e2e-config-test'
await r(['config', 'set', 'state.current_app', appId])
const result = await r(['config', 'get', 'state.current_app'])
assertExitCode(result, 0)
expect(result.stdout.trim()).toBe(appId)
})
it('[P0] config get returns empty string for an unset key (exit 0)', async () => {
// Spec: missing config fields fall back to default values
const result = await r(['config', 'get', 'defaults.format'])
assertExitCode(result, 0)
expect(result.stdout.trim()).toBe('')
})
it('[P1] multiple config set calls for different keys each persist independently', async () => {
// Spec: existing config is not overwritten when setting other keys
await r(['config', 'set', 'defaults.format', 'yaml'])
await r(['config', 'set', 'defaults.limit', '30'])
const fmt = await r(['config', 'get', 'defaults.format'])
const lim = await r(['config', 'get', 'defaults.limit'])
expect(fmt.stdout.trim()).toBe('yaml')
expect(lim.stdout.trim()).toBe('30')
})
// ── config unset ─────────────────────────────────────────────────────────────
it('[P0] config unset clears a set key — get returns empty string', async () => {
await r(['config', 'set', 'defaults.format', 'json'])
await r(['config', 'unset', 'defaults.format'])
const result = await r(['config', 'get', 'defaults.format'])
assertExitCode(result, 0)
expect(result.stdout.trim()).toBe('')
})
it('[P1] config unset of an unset key is idempotent (exit 0)', async () => {
const result = await r(['config', 'unset', 'defaults.format'])
assertExitCode(result, 0)
})
it('[P1] other keys are unaffected after config unset', async () => {
await r(['config', 'set', 'defaults.format', 'table'])
await r(['config', 'set', 'defaults.limit', '10'])
await r(['config', 'unset', 'defaults.format'])
const lim = await r(['config', 'get', 'defaults.limit'])
expect(lim.stdout.trim()).toBe('10')
})
// ── config view ──────────────────────────────────────────────────────────────
it('[P0] config view outputs nothing for an empty configuration', async () => {
const result = await r(['config', 'view'])
assertExitCode(result, 0)
expect(result.stdout.trim()).toBe('')
})
it('[P0] config view displays all set key = value pairs', async () => {
await r(['config', 'set', 'defaults.format', 'yaml'])
await r(['config', 'set', 'defaults.limit', '20'])
const result = await r(['config', 'view'])
assertExitCode(result, 0)
expect(result.stdout).toContain('defaults.format = yaml')
expect(result.stdout).toContain('defaults.limit = 20')
})
it('[P0] config view --json outputs valid JSON containing the set keys', async () => {
await r(['config', 'set', 'defaults.format', 'json'])
await r(['config', 'set', 'defaults.limit', '15'])
const result = await r(['config', 'view', '--json'])
assertExitCode(result, 0)
const parsed = JSON.parse(result.stdout) as Record<string, unknown>
expect(parsed).toHaveProperty('defaults.format', 'json')
expect(parsed).toHaveProperty('defaults.limit', 15)
})
it('[P1] config view --json outputs a valid empty JSON object for an empty config', async () => {
const result = await r(['config', 'view', '--json'])
assertExitCode(result, 0)
const parsed = JSON.parse(result.stdout)
expect(typeof parsed).toBe('object')
})
// ── Initialization and default paths ─────────────────────────────────────────
it('[P0] the first config set auto-creates the config directory and config.yml file', async () => {
// Spec: config directory and file are auto-created on first use
await r(['config', 'set', 'defaults.format', 'json'])
await expect(access(join(configDir, 'config.yml'))).resolves.toBeUndefined()
})
it('[P0] config.yml file permissions are 0o600', async () => {
// Spec: config file has correct default permissions
await r(['config', 'set', 'defaults.format', 'json'])
const info = await stat(join(configDir, 'config.yml'))
expect(info.mode & 0o777).toBe(0o600)
})
it('[P0] config.yml contains the correct schema_version field', async () => {
// Spec: config file has the correct default schema
await r(['config', 'set', 'defaults.format', 'json'])
const raw = await import('node:fs/promises').then(fs =>
fs.readFile(join(configDir, 'config.yml'), 'utf8'),
)
expect(raw).toMatch(/schema_version/)
})
it('[P0] invalid YAML content in config returns a parse error', async () => {
// Spec: invalid config content returns a parse error
await mkdir(configDir, { recursive: true })
await writeFile(join(configDir, 'config.yml'), ': broken: yaml: [[[', { mode: 0o600 })
const result = await r(['config', 'get', 'defaults.format'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/parse|yaml|config/i)
})
it('[P0] schema_version higher than supported returns config_schema_unsupported error', async () => {
// Spec: config with schema_version higher than supported returns an error
await mkdir(configDir, { recursive: true })
await writeFile(
join(configDir, 'config.yml'),
'schema_version: 999\ndefaults: {}\nstate: {}\n',
{ mode: 0o600 },
)
const result = await r(['config', 'get', 'defaults.format'])
expect(result.exitCode).toBe(6) // VersionCompat
expect(result.stderr).toMatch(/schema_version|unsupported|upgrade/i)
})
it('[P0] DIFY_CONFIG_DIR overrides the default path — config path returns the specified directory', async () => {
// Spec: DIFY_CONFIG_DIR env var overrides the default path
const altDir = await mkdtemp(join(tmpdir(), 'difyctl-alt-'))
try {
const result = await run(['config', 'path'], { configDir: altDir })
assertExitCode(result, 0)
expect(result.stdout.trim()).toBe(join(altDir, 'config.yml'))
}
finally {
await rm(altDir, { recursive: true, force: true })
}
})
it('[P0] a temporary DIFY_CONFIG_DIR does not modify the original config directory', async () => {
// Spec: a temporary DIFY_CONFIG_DIR injection does not modify the original config
await r(['config', 'set', 'defaults.format', 'yaml'])
const altDir = await mkdtemp(join(tmpdir(), 'difyctl-alt-'))
try {
await run(['config', 'set', 'defaults.format', 'json'], { configDir: altDir })
// The original configDir content must be unchanged
const original = await r(['config', 'get', 'defaults.format'])
expect(original.stdout.trim()).toBe('yaml')
}
finally {
await rm(altDir, { recursive: true, force: true })
}
})
// ── Error scenarios ──────────────────────────────────────────────────────────
it('[P0] config get of an unknown key returns exit code 2', async () => {
const result = await r(['config', 'get', 'unknown.key'])
expect(result.exitCode).toBe(2)
expect(result.stderr).toMatch(/unknown config key/i)
})
it('[P0] config set of an unknown key returns exit code 2', async () => {
const result = await r(['config', 'set', 'unknown.key', 'val'])
expect(result.exitCode).toBe(2)
expect(result.stderr).toMatch(/unknown config key/i)
})
it('[P0] config unset of an unknown key returns exit code 2', async () => {
const result = await r(['config', 'unset', 'unknown.key'])
expect(result.exitCode).toBe(2)
expect(result.stderr).toMatch(/unknown config key/i)
})
it('[P0] config set defaults.format with an invalid value returns exit code 2', async () => {
// Spec: config_invalid_value → usage error
const result = await r(['config', 'set', 'defaults.format', 'not_a_format'])
expect(result.exitCode).toBe(2)
expect(result.stderr).toMatch(/defaults\.format|not one of/i)
})
it('[P0] config set defaults.limit 0 (below minimum) returns exit code 2', async () => {
const result = await r(['config', 'set', 'defaults.limit', '0'])
expect(result.exitCode).toBe(2)
})
it('[P0] config set defaults.limit 201 (above maximum) returns exit code 2', async () => {
const result = await r(['config', 'set', 'defaults.limit', '201'])
expect(result.exitCode).toBe(2)
})
it('[P0] config set defaults.limit with a non-numeric string returns exit code 2', async () => {
const result = await r(['config', 'set', 'defaults.limit', 'abc'])
expect(result.exitCode).toBe(2)
})
it('[P1] config set with missing value argument returns an error', async () => {
const result = await r(['config', 'set', 'defaults.format'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/missing required argument/i)
})
it('[P1] config get with missing key argument returns an error', async () => {
const result = await r(['config', 'get'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/missing required argument/i)
})
// ── Output format ────────────────────────────────────────────────────────────
it('[P0] config output contains no ANSI colour (non-TTY environment)', async () => {
await r(['config', 'set', 'defaults.format', 'json'])
const result = await r(['config', 'view'])
assertExitCode(result, 0)
assertNoAnsi(result.stdout, 'stdout')
})
it('[P0] config initialization/operations do not leak sensitive information (token/secret)', async () => {
// Spec: config initialization logs do not leak sensitive information
const result = await r(['config', 'view'])
assertExitCode(result, 0)
expect(result.stdout + result.stderr).not.toMatch(/dfoa_|dfoe_|secret|password/i)
})
})

View File

@ -1,195 +0,0 @@
/**
* E2E: difyctl describe app — Describe App
*
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Discovery/Describe App (29 cases)
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
assertErrorEnvelope,
assertExitCode,
assertJson,
assertNoAnsi,
} from '../../helpers/assert.js'
import { withAuthFixture, withTempConfig } from '../../helpers/cli.js'
import { loadE2EEnv } from '../../setup/env.js'
const E = loadE2EEnv()
const NONEXISTENT_ID = 'app-does-not-exist-e2e-xyz'
describe('E2E / difyctl describe app', () => {
let fx: Awaited<ReturnType<typeof withAuthFixture>>
beforeEach(async () => {
fx = await withAuthFixture(E)
})
afterEach(async () => {
await fx.cleanup()
})
// ── Basic describe ────────────────────────────────────────────────────────
it('[P0] logged-in user can describe an app', async () => {
const result = await fx.r(['describe', 'app', E.chatAppId])
assertExitCode(result, 0)
expect(result.stdout.length).toBeGreaterThan(0)
})
it('[P0] default text output is labelled-section style', async () => {
// Spec: default output is kubectl-describe-style labelled sections
const result = await fx.r(['describe', 'app', E.chatAppId])
assertExitCode(result, 0)
// Labelled output contains key: value pairs
expect(result.stdout).toMatch(/\w+:\s+\S/)
})
it('[P1] describe output contains ID field', async () => {
const result = await fx.r(['describe', 'app', E.chatAppId])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/ID:/i)
expect(result.stdout).toContain(E.chatAppId)
})
it('[P1] describe output contains Mode field', async () => {
const result = await fx.r(['describe', 'app', E.chatAppId])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/Mode:/i)
})
it('[P1] describe output contains Name field', async () => {
const result = await fx.r(['describe', 'app', E.chatAppId])
assertExitCode(result, 0)
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 () => {
// Spec: Inputs/Parameters section present when app has an input schema
const result = await fx.r(['describe', 'app', E.workflowAppId])
assertExitCode(result, 0)
// Workflow app has at least a 'x' required input
expect(result.stdout).toMatch(/Parameters|Inputs/i)
})
// ── JSON output ───────────────────────────────────────────────────────────
it('[P0] -o json outputs the raw server describe response', async () => {
const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'json'])
assertExitCode(result, 0)
const parsed = assertJson<{ info: { id: string } }>(result)
expect(parsed.info?.id).toBe(E.chatAppId)
})
it('[P1] JSON output is valid indented JSON', async () => {
const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'json'])
assertExitCode(result, 0)
// Indented JSON: multiple lines, starts with '{'
expect(result.stdout.trim()).toMatch(/^\{/)
expect(result.stdout.split('\n').length).toBeGreaterThan(2)
})
it('[P1] JSON output can be piped', async () => {
const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'json'])
assertExitCode(result, 0)
expect(result.stdout.trimStart().startsWith('{')).toBe(true)
expect(result.stdout.endsWith('\n')).toBe(true)
})
// ── Unsupported formats ───────────────────────────────────────────────────
it('[P0] -o wide is not supported and returns an error', async () => {
const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'wide'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/NoCompatiblePrinter|invalid|unsupported|wide/i)
})
it('[P0] -o name is not supported and returns an error', async () => {
const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'name'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/NoCompatiblePrinter|invalid|unsupported|name/i)
})
// ── Not found ─────────────────────────────────────────────────────────────
it('[P0] describing a non-existent app returns an error', async () => {
const result = await fx.r(['describe', 'app', NONEXISTENT_ID])
expect(result.exitCode).not.toBe(0)
})
it('[P0] non-existent app exit code is 1', async () => {
const result = await fx.r(['describe', 'app', NONEXISTENT_ID])
expect(result.exitCode).toBe(1)
})
it('[P1] non-existent app in JSON mode outputs JSON error envelope', async () => {
const result = await fx.r(['describe', 'app', NONEXISTENT_ID, '-o', 'json'])
expect(result.exitCode).not.toBe(0)
assertErrorEnvelope(result)
})
// ── Missing argument ──────────────────────────────────────────────────────
it('[P1] missing app id returns usage error', async () => {
const result = await fx.r(['describe', 'app'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/missing required argument|required/i)
})
// ── Unauthenticated ───────────────────────────────────────────────────────
it('[P0] unauthenticated describe app returns auth error', async () => {
const tmp = await withTempConfig()
try {
const { run } = await import('../../helpers/cli.js')
const result = await run(['describe', 'app', E.chatAppId], { configDir: tmp.configDir })
assertExitCode(result, 4)
expect(result.stderr).toMatch(/not.?logged.?in|auth/i)
}
finally {
await tmp.cleanup()
}
})
// ── External SSO ──────────────────────────────────────────────────────────
it('[P0] external SSO user describe app returns insufficient_scope', async () => {
const { mkdir, writeFile } = await import('node:fs/promises')
const { join } = await import('node:path')
const { withTempConfig: wtc, run } = await import('../../helpers/cli.js')
const ssoTmp = await wtc()
try {
await mkdir(ssoTmp.configDir, { recursive: true })
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
`tokens:`,
` bearer: dfoe_sso_test_token`,
`external_subject:`,
` email: sso@example.com`,
` issuer: https://issuer.example.com`,
].join('\n')}\n`
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await run(['describe', 'app', E.chatAppId], { configDir: ssoTmp.configDir })
// SSO subjects have no workspace; CLI reports usage_missing_arg before reaching scope check
expect(result.exitCode).not.toBe(0)
}
finally {
await ssoTmp.cleanup()
}
})
// ── Output quality ────────────────────────────────────────────────────────
it('[P0] describe output has no ANSI colour codes (non-TTY)', async () => {
const result = await fx.r(['describe', 'app', E.chatAppId])
assertExitCode(result, 0)
assertNoAnsi(result.stdout, 'stdout')
})
})

View File

@ -1,219 +0,0 @@
/**
* E2E: difyctl get app -A — Cross-Workspace App Query
*
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Discovery/Cross-Workspace Query (22 cases)
*
* Note: Most cases require the test account to have multiple workspaces.
* Tests that depend on multiple workspaces are guarded by checking the
* available_workspaces count from auth status.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
assertErrorEnvelope,
assertExitCode,
assertJson,
assertNoAnsi,
assertPipeFriendlyJson,
} from '../../helpers/assert.js'
import { withAuthFixture, withTempConfig } from '../../helpers/cli.js'
import { loadE2EEnv } from '../../setup/env.js'
const E = loadE2EEnv()
describe('E2E / difyctl get app -A (all-workspaces)', () => {
let fx: Awaited<ReturnType<typeof withAuthFixture>>
beforeEach(async () => {
fx = await withAuthFixture(E)
})
afterEach(async () => {
await fx.cleanup()
})
// ── Basic fan-out ─────────────────────────────────────────────────────────
it('[P0] internal user can execute all-workspaces query', async () => {
const result = await fx.r(['get', 'app', '-A', '-o', 'json'])
assertExitCode(result, 0)
const parsed = assertJson<{ data: unknown[] }>(result)
expect(Array.isArray(parsed.data)).toBe(true)
})
it('[P1] --all-workspaces and -A flags behave identically', async () => {
const r1 = await fx.r(['get', 'app', '-A', '-o', 'json'])
const r2 = await fx.r(['get', 'app', '--all-workspaces', '-o', 'json'])
assertExitCode(r1, 0)
assertExitCode(r2, 0)
// Both return same structure
const p1 = assertJson<{ data: unknown[] }>(r1)
const p2 = assertJson<{ data: unknown[] }>(r2)
expect(p1.data.length).toBe(p2.data.length)
})
// ── Output format ─────────────────────────────────────────────────────────
it('[P0] table output contains WORKSPACE column (or workspace_id in JSON)', async () => {
// WORKSPACE column appears in table only when apps span multiple workspaces.
// Verify via JSON that workspace_id is populated instead.
const result = await fx.r(['get', 'app', '-A', '-o', 'json'])
assertExitCode(result, 0)
const parsed = assertJson<{ data: Array<{ workspace_id?: string }> }>(result)
if (parsed.data.length > 0) {
const hasWorkspace = parsed.data.some(a => typeof a.workspace_id === 'string' && a.workspace_id.length > 0)
expect(hasWorkspace).toBe(true)
}
})
it('[P0] JSON output contains workspace_id per app', async () => {
const result = await fx.r(['get', 'app', '-A', '-o', 'json'])
assertExitCode(result, 0)
const parsed = assertJson<{ data: Array<{ workspace_id?: string }> }>(result)
if (parsed.data.length > 0) {
// At least the known workspace should be represented
const hasWorkspaceId = parsed.data.some(app => typeof app.workspace_id === 'string')
expect(hasWorkspaceId).toBe(true)
}
})
it('[P1] YAML output contains workspace_id', async () => {
const result = await fx.r(['get', 'app', '-A', '-o', 'yaml'])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/workspace_id/)
})
it('[P1] all-workspaces output is pipe-friendly in JSON mode', async () => {
const result = await fx.r(['get', 'app', '-A', '-o', 'json'])
assertExitCode(result, 0)
assertPipeFriendlyJson(result)
})
it('[P0] all-workspaces output has no ANSI colour codes (non-TTY)', async () => {
const result = await fx.r(['get', 'app', '-A'])
assertExitCode(result, 0)
assertNoAnsi(result.stdout, 'stdout')
})
// ── Filters in all-workspaces mode ────────────────────────────────────────
it('[P1] --limit applies in all-workspaces mode', async () => {
const result = await fx.r(['get', 'app', '-A', '--limit', '1', '-o', 'json'])
assertExitCode(result, 0)
// limit applies per workspace; total may be > 1 across workspaces
// but the call itself must succeed
const parsed = assertJson<{ data: unknown[] }>(result)
expect(Array.isArray(parsed.data)).toBe(true)
})
it('[P1] --mode filter applies in all-workspaces mode', async () => {
const result = await fx.r(['get', 'app', '-A', '--mode', 'workflow', '-o', 'json'])
assertExitCode(result, 0)
const parsed = assertJson<{ data: Array<{ mode: string }> }>(result)
parsed.data.forEach(app => expect(app.mode).toBe('workflow'))
})
// ── Unauthenticated ───────────────────────────────────────────────────────
it('[P0] unauthenticated get app -A returns auth error', async () => {
const tmp = await withTempConfig()
try {
const { run } = await import('../../helpers/cli.js')
const result = await run(['get', 'app', '-A'], { configDir: tmp.configDir })
assertExitCode(result, 4)
expect(result.stderr).toMatch(/not.?logged.?in|auth/i)
}
finally {
await tmp.cleanup()
}
})
it('[P0] unauthenticated -A exit code is 4', async () => {
const tmp = await withTempConfig()
try {
const { run } = await import('../../helpers/cli.js')
const result = await run(['get', 'app', '-A'], { configDir: tmp.configDir })
expect(result.exitCode).toBe(4)
}
finally {
await tmp.cleanup()
}
})
// ── External SSO ──────────────────────────────────────────────────────────
it('[P0] external SSO user get app -A returns error', async () => {
const { mkdir, writeFile } = await import('node:fs/promises')
const { join } = await import('node:path')
const { withTempConfig: wtc, run } = await import('../../helpers/cli.js')
const ssoTmp = await wtc()
try {
await mkdir(ssoTmp.configDir, { recursive: true })
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
`tokens:`,
` bearer: dfoe_sso_test_token`,
`external_subject:`,
` email: sso@example.com`,
` issuer: https://issuer.example.com`,
].join('\n')}\n`
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await run(['get', 'app', '-A'], { configDir: ssoTmp.configDir })
expect(result.exitCode).not.toBe(0)
}
finally {
await ssoTmp.cleanup()
}
})
it('[P0] external SSO user -A exit code is not 0', async () => {
const { mkdir, writeFile } = await import('node:fs/promises')
const { join } = await import('node:path')
const { withTempConfig: wtc, run } = await import('../../helpers/cli.js')
const ssoTmp = await wtc()
try {
await mkdir(ssoTmp.configDir, { recursive: true })
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
`tokens:`,
` bearer: dfoe_sso_test_token`,
`external_subject:`,
` email: sso@example.com`,
` issuer: https://issuer.example.com`,
].join('\n')}\n`
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await run(['get', 'app', '-A'], { configDir: ssoTmp.configDir })
// SSO subject has no workspace, so all-workspaces returns error
expect(result.exitCode).not.toBe(0)
}
finally {
await ssoTmp.cleanup()
}
})
// ── JSON error envelope ───────────────────────────────────────────────────
it('[P1] JSON mode error outputs JSON error envelope to stderr', async () => {
const tmp = await withTempConfig()
try {
const { run } = await import('../../helpers/cli.js')
const result = await run(['get', 'app', '-A', '-o', 'json'], { configDir: tmp.configDir })
expect(result.exitCode).not.toBe(0)
assertErrorEnvelope(result)
}
finally {
await tmp.cleanup()
}
})
// ── Stability ─────────────────────────────────────────────────────────────
it('[P1] using -A with -w together returns a stable result or clear error', async () => {
// Spec: behaviour when both flags are provided should be stable
const result = await fx.r(['get', 'app', '-A', '-w', E.workspaceId, '-o', 'json'])
// Either success (ignores -w) or a clear usage/logical error — must not panic
const isValid = result.exitCode === 0 || result.exitCode === 1 || result.exitCode === 2
expect(isValid).toBe(true)
})
})

View File

@ -1,247 +0,0 @@
/**
* E2E: difyctl get app (list mode) — App List
*
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Discovery/App List (31 cases)
*
* Prerequisites (DIFY_E2E_* env vars):
* DIFY_E2E_CHAT_APP_ID — echo-chat app
* DIFY_E2E_WORKFLOW_APP_ID — echo-workflow app
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
assertErrorEnvelope,
assertExitCode,
assertJson,
assertNoAnsi,
assertPipeFriendlyJson,
} from '../../helpers/assert.js'
import { withAuthFixture, withTempConfig } from '../../helpers/cli.js'
import { loadE2EEnv } from '../../setup/env.js'
const E = loadE2EEnv()
describe('E2E / difyctl get app (list)', () => {
let fx: Awaited<ReturnType<typeof withAuthFixture>>
beforeEach(async () => {
fx = await withAuthFixture(E)
})
afterEach(async () => {
await fx.cleanup()
})
// ── Basic listing ─────────────────────────────────────────────────────────
it('[P0] logged-in user can retrieve app list', async () => {
const result = await fx.r(['get', 'app'])
assertExitCode(result, 0)
expect(result.stdout.length).toBeGreaterThan(0)
})
it('[P0] default output format is table', async () => {
const result = await fx.r(['get', 'app'])
assertExitCode(result, 0)
// table output: has column headers, no leading '{' (not JSON)
expect(result.stdout.trimStart()).not.toMatch(/^\{/)
})
it('[P1] table output contains app ID', async () => {
const result = await fx.r(['get', 'app'])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/ID/i)
})
it('[P1] table output contains app name', async () => {
const result = await fx.r(['get', 'app'])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/NAME/i)
})
it('[P1] table output contains mode column', async () => {
const result = await fx.r(['get', 'app'])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/MODE/i)
})
// ── Output formats ────────────────────────────────────────────────────────
it('[P0] -o json outputs valid JSON', async () => {
const result = await fx.r(['get', 'app', '-o', 'json'])
assertExitCode(result, 0)
const parsed = assertJson<{ data: unknown[] }>(result)
expect(Array.isArray(parsed.data)).toBe(true)
})
it('[P1] -o yaml outputs valid YAML (non-empty, no JSON braces)', async () => {
const result = await fx.r(['get', 'app', '-o', 'yaml'])
assertExitCode(result, 0)
expect(result.stdout.length).toBeGreaterThan(0)
// YAML lists start with '- ' not '{'
expect(result.stdout.trimStart()).not.toMatch(/^\{/)
})
it('[P1] -o name outputs only app IDs (one per line)', async () => {
const result = await fx.r(['get', 'app', '-o', 'name'])
assertExitCode(result, 0)
const lines = result.stdout.trim().split('\n').filter(Boolean)
expect(lines.length).toBeGreaterThan(0)
// Each line should look like a UUID
expect(lines[0]).toMatch(/^[0-9a-f-]{36}$/)
})
it('[P1] -o wide outputs extended fields', async () => {
const result = await fx.r(['get', 'app', '-o', 'wide'])
assertExitCode(result, 0)
// wide adds AUTHOR and WORKSPACE columns
expect(result.stdout).toMatch(/AUTHOR|WORKSPACE/i)
})
it('[P1] output is pipe-friendly in JSON mode', async () => {
const result = await fx.r(['get', 'app', '-o', 'json'])
assertExitCode(result, 0)
assertPipeFriendlyJson(result)
})
it('[P0] output has no ANSI colour codes (non-TTY)', async () => {
const result = await fx.r(['get', 'app'])
assertExitCode(result, 0)
assertNoAnsi(result.stdout, 'stdout')
})
// ── --limit ───────────────────────────────────────────────────────────────
it('[P0] --limit restricts number of returned apps', async () => {
const result = await fx.r(['get', 'app', '--limit', '1', '-o', 'json'])
assertExitCode(result, 0)
const parsed = assertJson<{ data: unknown[] }>(result)
expect(parsed.data.length).toBeLessThanOrEqual(1)
})
it('[P1] --limit 1 returns exactly one result', async () => {
const result = await fx.r(['get', 'app', '--limit', '1', '-o', 'json'])
assertExitCode(result, 0)
const parsed = assertJson<{ data: unknown[] }>(result)
expect(parsed.data.length).toBe(1)
})
it('[P0] --limit 0 returns usage error (exit code 2)', async () => {
const result = await fx.r(['get', 'app', '--limit', '0'])
expect(result.exitCode).toBe(2)
})
it('[P0] --limit 201 returns usage error (exit code 2)', async () => {
const result = await fx.r(['get', 'app', '--limit', '201'])
expect(result.exitCode).toBe(2)
})
// ── --mode filter ─────────────────────────────────────────────────────────
it('[P0] --mode chat filters to chat apps only', async () => {
const result = await fx.r(['get', 'app', '--mode', 'chat', '-o', 'json'])
assertExitCode(result, 0)
const parsed = assertJson<{ data: Array<{ mode: string }> }>(result)
parsed.data.forEach(app => expect(app.mode).toBe('chat'))
})
it('[P0] --mode workflow filters to workflow apps only', async () => {
const result = await fx.r(['get', 'app', '--mode', 'workflow', '-o', 'json'])
assertExitCode(result, 0)
const parsed = assertJson<{ data: Array<{ mode: string }> }>(result)
parsed.data.forEach(app => expect(app.mode).toBe('workflow'))
})
it('[P0] --mode with a valid enum value succeeds', async () => {
// Spec: valid enum filter returns successfully
const result = await fx.r(['get', 'app', '--mode', 'workflow', '-o', 'json'])
assertExitCode(result, 0)
})
it('[P1] --mode with unknown value returns empty list or usage error', async () => {
// Spec: invalid mode — CLI intercepts (oclif validates enum options, returns non-zero)
const result = await fx.r(['get', 'app', '--mode', 'chatbot', '-o', 'json'])
expect(result.exitCode).not.toBe(0)
})
// ── workspace override ────────────────────────────────────────────────────
it('[P0] -w overrides the default workspace', async () => {
// Pass the known workspace id — should return apps for that workspace
const result = await fx.r(['get', 'app', '--workspace', E.workspaceId, '-o', 'json'])
assertExitCode(result, 0)
const parsed = assertJson<{ data: unknown[] }>(result)
expect(Array.isArray(parsed.data)).toBe(true)
})
// ── Unauthenticated ───────────────────────────────────────────────────────
it('[P0] unauthenticated get app returns auth error', async () => {
const tmp = await withTempConfig()
try {
const { run } = await import('../../helpers/cli.js')
const result = await run(['get', 'app'], { configDir: tmp.configDir })
assertExitCode(result, 4)
expect(result.stderr).toMatch(/not.?logged.?in|auth/i)
}
finally {
await tmp.cleanup()
}
})
it('[P0] unauthenticated get app exit code is 4', async () => {
const tmp = await withTempConfig()
try {
const { run } = await import('../../helpers/cli.js')
const result = await run(['get', 'app'], { configDir: tmp.configDir })
expect(result.exitCode).toBe(4)
}
finally {
await tmp.cleanup()
}
})
// ── External SSO ──────────────────────────────────────────────────────────
it('[P0] external SSO user get app returns insufficient_scope error', async () => {
const { mkdir, writeFile } = await import('node:fs/promises')
const { join } = await import('node:path')
const { withTempConfig: wtc } = await import('../../helpers/cli.js')
const ssoTmp = await wtc()
try {
await mkdir(ssoTmp.configDir, { recursive: true })
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
`tokens:`,
` bearer: dfoe_sso_test_token`,
`external_subject:`,
` email: sso@example.com`,
` issuer: https://issuer.example.com`,
].join('\n')}\n`
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const { run } = await import('../../helpers/cli.js')
const result = await run(['get', 'app'], { configDir: ssoTmp.configDir })
expect(result.exitCode).not.toBe(0)
// SSO subjects have no workspace; CLI reports usage_missing_arg before reaching scope check
expect(result.exitCode).not.toBe(0)
}
finally {
await ssoTmp.cleanup()
}
})
// ── JSON error envelope ───────────────────────────────────────────────────
it('[P1] JSON mode error outputs JSON error envelope to stderr', async () => {
const tmp = await withTempConfig()
try {
const { run } = await import('../../helpers/cli.js')
const result = await run(['get', 'app', '-o', 'json'], { configDir: tmp.configDir })
expect(result.exitCode).not.toBe(0)
assertErrorEnvelope(result)
}
finally {
await tmp.cleanup()
}
})
})

View File

@ -1,104 +0,0 @@
/**
* E2E: difyctl get app <id> — Single App Query
*
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Discovery/Single App Query (22 cases)
*
* Note: difyctl get app <id> calls GET /apps/<id>/describe?fields=info internally.
* Server 1.14.1 returns HTTP 500 for all app IDs on this endpoint, so tests that
* require a successful single-app lookup are deferred to a compatible server version.
* The non-network tests (unauthenticated, not-found, error format) are covered here.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
assertErrorEnvelope,
assertExitCode,
} from '../../helpers/assert.js'
import { withAuthFixture, withTempConfig } from '../../helpers/cli.js'
import { loadE2EEnv } from '../../setup/env.js'
const E = loadE2EEnv()
const NONEXISTENT_ID = 'app-does-not-exist-e2e-xyz'
describe('E2E / difyctl get app <id> (single)', () => {
let fx: Awaited<ReturnType<typeof withAuthFixture>>
beforeEach(async () => {
fx = await withAuthFixture(E)
})
afterEach(async () => {
await fx.cleanup()
})
// ── Not found ─────────────────────────────────────────────────────────────
it('[P0] querying a non-existent app returns a non-zero exit code', async () => {
const result = await fx.r(['get', 'app', NONEXISTENT_ID])
expect(result.exitCode).not.toBe(0)
})
it('[P0] non-existent app exit code is 1', async () => {
const result = await fx.r(['get', 'app', NONEXISTENT_ID])
expect(result.exitCode).toBe(1)
})
it('[P1] JSON mode error for non-existent app outputs JSON error envelope', async () => {
const result = await fx.r(['get', 'app', NONEXISTENT_ID, '-o', 'json'])
expect(result.exitCode).not.toBe(0)
assertErrorEnvelope(result)
})
// ── Unauthenticated ───────────────────────────────────────────────────────
it('[P0] unauthenticated get app <id> returns auth error (exit code 4)', async () => {
const tmp = await withTempConfig()
try {
const { run } = await import('../../helpers/cli.js')
const result = await run(['get', 'app', E.workflowAppId], { configDir: tmp.configDir })
assertExitCode(result, 4)
expect(result.stderr).toMatch(/not.?logged.?in|auth/i)
}
finally {
await tmp.cleanup()
}
})
it('[P0] unauthenticated exit code is 4', async () => {
const tmp = await withTempConfig()
try {
const { run } = await import('../../helpers/cli.js')
const result = await run(['get', 'app', E.workflowAppId], { configDir: tmp.configDir })
expect(result.exitCode).toBe(4)
}
finally {
await tmp.cleanup()
}
})
// ── External SSO ──────────────────────────────────────────────────────────
it('[P0] external SSO user get app <id> returns a non-zero exit code', async () => {
const { mkdir, writeFile } = await import('node:fs/promises')
const { join } = await import('node:path')
const { withTempConfig: wtc, run } = await import('../../helpers/cli.js')
const ssoTmp = await wtc()
try {
await mkdir(ssoTmp.configDir, { recursive: true })
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
`tokens:`,
` bearer: dfoe_sso_test_token`,
`external_subject:`,
` email: sso@example.com`,
` issuer: https://issuer.example.com`,
].join('\n')}\n`
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await run(['get', 'app', E.workflowAppId], { configDir: ssoTmp.configDir })
expect(result.exitCode).not.toBe(0)
}
finally {
await ssoTmp.cleanup()
}
})
})

View File

@ -1,446 +0,0 @@
/**
* E2E: difyctl run app — basic app execution
*
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Run/Basic App Execution (4.1)
*
* Streaming output cases → run-app-streaming.e2e.ts
* Conversation mode cases → run-app-conversation.e2e.ts
*
* Staging app prerequisites (specified via DIFY_E2E_* env vars):
* echo-chat — mode=chat, query variable, outputs "echo: {query}"
* echo-workflow — mode=workflow, x variable (required), outputs "echo: {x}"
*/
import type { AuthFixture } from '../../helpers/cli.js'
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
assertErrorEnvelope,
assertExitCode,
assertJson,
assertNoAnsi,
assertPipeFriendlyJson,
assertStdoutContains,
} from '../../helpers/assert.js'
import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
import { withRetry } from '../../helpers/retry.js'
import { optionalIt } from '../../helpers/skip.js'
import { loadE2EEnv } from '../../setup/env.js'
const E = loadE2EEnv()
const itWithSso = optionalIt(Boolean(E.ssoToken))
// ── Suite ──────────────────────────────────────────────────────────────────
describe('E2E / difyctl run app', () => {
let fx: AuthFixture
beforeEach(async () => {
fx = await withAuthFixture(E)
})
afterEach(async () => {
await fx.cleanup()
})
// =========================================================================
// Basic execution
// =========================================================================
describe('Basic execution', () => {
it('[P0] logged-in internal user can run app — stdout contains the app result', async () => {
// Spec: logged-in internal user can run app / default output shows execution result
// withRetry: staging LLM inference may have transient 5xx on cold start
const result = await withRetry(() => fx.r(['run', 'app', E.chatAppId, 'hello']), {
attempts: 3,
delayMs: 2000,
shouldRetry: err => err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message),
})
assertExitCode(result, 0)
assertStdoutContains(result, 'echo:hello')
// Spec 4.1.4: default output has no ANSI colour codes (non-TTY; run() sets NO_COLOR=1)
assertNoAnsi(result.stdout, 'stdout')
})
it('[P0] run app invokes the execute endpoint (stdout has actual content)', async () => {
// Spec: run app invokes the execute endpoint
const result = await fx.r(['run', 'app', E.chatAppId, 'e2e-smoke'])
assertExitCode(result, 0)
expect(result.stdout.length).toBeGreaterThan(0)
})
it('[P1] text output preserves newlines (stdout ends with \\n)', async () => {
// Spec: text output preserves newlines
const result = await fx.r(['run', 'app', E.chatAppId, 'newline'])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/\n$/)
})
it('[P1] repeated run app calls each complete independently (3 iterations)', async () => {
// Spec: repeated run app calls do not affect historical state
for (let i = 0; i < 3; i++) {
const result = await fx.r(['run', 'app', E.chatAppId, `repeat-${i}`])
assertExitCode(result, 0)
assertStdoutContains(result, `echo:repeat-${i}`)
}
})
})
// =========================================================================
// Output format
// =========================================================================
describe('Output format (-o)', () => {
it('[P0] -o json outputs valid JSON', async () => {
// Spec: -o json produces valid JSON
const result = await fx.r(['run', 'app', E.chatAppId, 'json-test', '-o', 'json'])
assertExitCode(result, 0)
const parsed = assertJson<{ answer: string, mode: string }>(result)
expect(parsed).toHaveProperty('answer')
expect(parsed.mode).toMatch(/chat/)
})
it('[P1] JSON output includes execution metadata (message_id / conversation_id)', async () => {
// Spec: JSON output includes execution metadata
const result = await fx.r(['run', 'app', E.chatAppId, 'meta', '-o', 'json'])
assertExitCode(result, 0)
const parsed = assertJson<Record<string, unknown>>(result)
expect(parsed).toHaveProperty('message_id')
expect(parsed).toHaveProperty('conversation_id')
})
it('[P1] JSON output supports piping (no ANSI, starts with {, ends with \\n)', async () => {
// Spec: JSON output supports piping
const result = await fx.r(['run', 'app', E.chatAppId, 'pipe', '-o', 'json'])
assertExitCode(result, 0)
assertPipeFriendlyJson(result)
})
it('[P1] JSON mode outputs a JSON error envelope to stderr', async () => {
// Spec: JSON mode outputs a JSON error envelope
const result = await fx.r(['run', 'app', 'app-nonexistent-xyz-e2e', 'hello', '-o', 'json'])
assertNonZeroExit(result)
assertErrorEnvelope(result, 'server_4xx_other')
})
})
// =========================================================================
// --inputs flag
// =========================================================================
describe('--inputs flag', () => {
it('[P0] run app supports --inputs (workflow app)', async () => {
// Spec: run app supports --inputs
// withRetry: staging workflow execution may have transient 5xx
const result = await withRetry(
() => fx.r(['run', 'app', E.workflowAppId, '--inputs', JSON.stringify({ x: 'workflow-val', num: 42, enum_var: 'A', paragraph: 'short text' })]),
{ attempts: 3, delayMs: 2000, shouldRetry: err => err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message) },
)
assertExitCode(result, 0)
assertStdoutContains(result, 'workflow-val')
})
it('[P0] multiple inputs take effect simultaneously', async () => {
// Spec: multiple --inputs entries take effect simultaneously
const result = await fx.r([
'run',
'app',
E.workflowAppId,
'--inputs',
JSON.stringify({ x: 'multi-test', num: 42, enum_var: 'A', paragraph: 'short text' }),
])
assertExitCode(result, 0)
})
it('[P0] invalid JSON for --inputs returns usage error (exit code 2)', async () => {
// Spec: missing required parameter / invalid input
const result = await fx.r(['run', 'app', E.workflowAppId, '--inputs', 'not-json'])
assertExitCode(result, 2)
expect(result.stderr).toMatch(/valid JSON/i)
})
it('[P0] JSON array for --inputs returns usage error', async () => {
const result = await fx.r(['run', 'app', E.workflowAppId, '--inputs', '[1,2,3]'])
assertExitCode(result, 2)
expect(result.stderr).toMatch(/JSON object/i)
})
it('[P0] --inputs and --inputs-file are mutually exclusive — returns usage error', async () => {
// Spec: mutually exclusive flags return a usage error
const inputsFile = join(fx.configDir, 'inputs.json')
await writeFile(inputsFile, JSON.stringify({ x: 'file-val' }))
const result = await fx.r([
'run',
'app',
E.workflowAppId,
'--inputs',
'{"x":"flag-val"}',
'--inputs-file',
inputsFile,
])
assertExitCode(result, 2)
expect(result.stderr).toMatch(/mutually exclusive/i)
})
it('[P0] positional message passed to workflow app returns usage error', async () => {
// Spec: execution fails when required positional parameter is missing (workflow)
const result = await fx.r(['run', 'app', E.workflowAppId, 'positional-msg'])
assertExitCode(result, 2)
expect(result.stderr).toMatch(/workflow apps do not accept a positional message/i)
})
it('[P0] --inputs-file reads JSON inputs from a file', async () => {
const inputsFile = join(fx.configDir, 'wf-inputs.json')
await writeFile(inputsFile, JSON.stringify({ x: 'from-file', num: 42, enum_var: 'A', paragraph: 'short text' }))
const result = await fx.r(['run', 'app', E.workflowAppId, '--inputs-file', inputsFile])
assertExitCode(result, 0)
assertStdoutContains(result, 'from-file')
})
it('[P0] required inputs missing causes execution failure (exit code non-zero)', async () => {
// Spec 4.1.11: workflow app fails when required inputs are not provided.
// Passing an empty object omits the required "x" field; the server
// returns a validation error and the CLI exits with a non-zero code.
const result = await fx.r(['run', 'app', E.workflowAppId, '--inputs', '{}'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr.length).toBeGreaterThan(0)
})
it('[P0] paragraph input within limit succeeds; exceeding max_length returns error', async () => {
// Spec 4.1.19: paragraph input exceeding max_length (100) returns validation error
// App: basic_auto_test — variable "paragraph" (text-input, max_length=100, optional)
// ── Within limit: 50 chars ──────────────────────────────────────────
const shortResult = await fx.r([
'run',
'app',
E.workflowAppId,
'--inputs',
JSON.stringify({
x: 'hello',
num: 42,
enum_var: 'A',
paragraph: 'A'.repeat(50),
}),
])
assertExitCode(shortResult, 0)
// ── Exceeding limit: 101 chars ──────────────────────────────────────
const longResult = await fx.r([
'run',
'app',
E.workflowAppId,
'--inputs',
JSON.stringify({
x: 'hello',
num: 42,
enum_var: 'A',
paragraph: 'A'.repeat(101),
}),
])
expect(longResult.exitCode).not.toBe(0)
expect(longResult.stderr).toMatch(/paragraph.*less than 100|paragraph.*100 characters/i)
})
it('[P0] valid inputs of all types execute successfully; invalid typed/enum inputs return errors', async () => {
// Spec 4.1.17: non-typed input value returns a validation error
// Spec 4.1.18: invalid enum value returns a validation error
//
// App: basic_auto_test (DIFY_E2E_WORKFLOW_APP_ID)
// Input schema:
// x — text-input (required)
// num — number (required, Spec 4.1.17)
// enum_var — select (required, options: A/B/C, Spec 4.1.18)
// ── Happy path: all correct values ──────────────────────────────────
const happyResult = await fx.r([
'run',
'app',
E.workflowAppId,
'--inputs',
JSON.stringify({ x: 'hello', num: 42, enum_var: 'A', paragraph: 'short text' }),
])
assertExitCode(happyResult, 0)
assertStdoutContains(happyResult, 'echo:hello')
// ── 4.1.17: number field receives a string value ─────────────────────
const typedResult = await fx.r([
'run',
'app',
E.workflowAppId,
'--inputs',
JSON.stringify({ x: 'hello', num: 'not-a-number', enum_var: 'A' }),
])
expect(typedResult.exitCode).not.toBe(0)
expect(typedResult.stderr).toMatch(/num.*number|must be a valid number/i)
// ── 4.1.18: enum field receives a value outside the allowed options ──
const enumResult = await fx.r([
'run',
'app',
E.workflowAppId,
'--inputs',
JSON.stringify({ x: 'hello', num: 42, enum_var: 'invalid' }),
])
expect(enumResult.exitCode).not.toBe(0)
expect(enumResult.stderr).toMatch(/enum_var.*must be one of|one of the following/i)
})
})
// =========================================================================
// Error scenarios
// =========================================================================
describe('Error scenarios', () => {
it('[P0] non-existent app returns error — exit code 1', async () => {
// Spec 4.1.20: non-existent app returns an error with not-found message
// Spec 4.1.21: exit code is exactly 1
const result = await fx.r(['run', 'app', 'app-id-does-not-exist-e2e-xyz', 'hello'])
assertExitCode(result, 1)
expect(result.stderr).toMatch(/not.?found/i)
})
it('[P0] missing app id returns error (exit code 1 — CLI returns 1 for missing required arg)', async () => {
// Spec: missing app id returns a usage error
// Actual behaviour: CLI framework returns exit 1 (not 2) for missing required argument
const result = await fx.r(['run', 'app'])
assertExitCode(result, 1)
expect(result.stderr).toMatch(/missing required argument/i)
})
it('[P0] unauthenticated run app returns auth error (exit code 4)', async () => {
// Spec 4.1.22: unauthenticated run app returns auth error message
// Spec 4.1.23: exit code is exactly 4
const unauthTmp = await withTempConfig()
try {
const result = await run(['run', 'app', E.chatAppId, 'hello'], {
configDir: unauthTmp.configDir,
})
assertExitCode(result, 4)
expect(result.stderr).toMatch(/not.?logged.?in|auth.?login/i)
}
finally {
await unauthTmp.cleanup()
}
})
it('[P1] network error returns non-zero exit code and error message', async () => {
// Spec 4.1.26: when the host is unreachable the CLI returns a network error.
// Uses a local port that has nothing listening (127.0.0.1:19999) so the
// connection is refused immediately without waiting for DNS.
const networkTmp = await withTempConfig()
try {
await mkdir(networkTmp.configDir, { recursive: true })
const hostsYml = `${[
`current_host: http://127.0.0.1:19999`,
`token_storage: file`,
`tokens:`,
` bearer: dfoa_fake_token_network_test`,
`workspace:`,
` id: ${E.workspaceId}`,
` name: "E2E Test Workspace"`,
` role: owner`,
`available_workspaces:`,
` - id: ${E.workspaceId}`,
` name: "E2E Test Workspace"`,
` role: owner`,
].join('\n')}\n`
await writeFile(join(networkTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await run(
['run', 'app', E.chatAppId, 'hello'],
{ configDir: networkTmp.configDir, timeout: 15_000 },
)
expect(result.exitCode).not.toBe(0)
expect(result.stderr.length).toBeGreaterThan(0)
}
finally {
await networkTmp.cleanup()
}
})
})
// =========================================================================
// Non-interactive mode / CI environment
// =========================================================================
describe('Non-interactive mode (CI)', () => {
it('[P0] CI=1 environment has no spinner — stdout has no ANSI colour', async () => {
// Spec: ANSI colour is disabled in non-TTY environment; spinner is suppressed in non-interactive mode
const result = await fx.r(['run', 'app', E.chatAppId, 'ci-test'], { CI: '1', NO_COLOR: '1' })
assertExitCode(result, 0)
assertNoAnsi(result.stdout, 'stdout')
assertNoAnsi(result.stderr, 'stderr')
})
it('[P0] non-interactive mode exit code is correctly propagated', async () => {
// Spec: non-interactive mode exit code is correct
const result = await fx.r(['run', 'app', E.chatAppId, 'code'])
expect(typeof result.exitCode).toBe('number')
expect(result.exitCode).toBe(0)
})
})
// =========================================================================
// Workspace override
// =========================================================================
describe('workspace override', () => {
it('[P1] --workspace flag overrides the default workspace', async () => {
// Spec: workspace override takes effect
// run app uses --workspace (no -w short form)
const result = await fx.r([
'run',
'app',
E.chatAppId,
'ws-override',
'--workspace',
E.workspaceId,
])
assertExitCode(result, 0)
})
itWithSso('[P1] external SSO user: --workspace parameter is silently ignored', async () => {
// Spec 4.1.25: SSO subjects operate without workspace scoping.
// Passing --workspace must not change the outcome — the parameter
// should be ignored, so both calls produce the same exit code.
const ssoTmp = await withTempConfig()
try {
await mkdir(ssoTmp.configDir, { recursive: true })
const hostsYml = `${[
`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`
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
// Run WITHOUT --workspace
const resultWithout = await run(
['run', 'app', E.chatAppId, 'hello'],
{ configDir: ssoTmp.configDir },
)
// Run WITH --workspace (should be ignored → same exit code)
const resultWith = await run(
['run', 'app', E.chatAppId, 'hello', '--workspace', E.workspaceId],
{ configDir: ssoTmp.configDir },
)
// If --workspace were honoured for SSO users it would change behaviour;
// identical exit codes confirm the parameter is silently ignored.
expect(resultWith.exitCode).toBe(resultWithout.exitCode)
}
finally {
await ssoTmp.cleanup()
}
})
})
})
// ── local helper (avoids import confusion) ─────────────────────────────────
function assertNonZeroExit(result: import('../../helpers/cli.js').RunResult): void {
expect(result.exitCode, 'exit code should be non-zero').not.toBe(0)
}

View File

@ -1,362 +0,0 @@
/**
* E2E: difyctl run app --conversation — Conversation mode
*
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Run/Conversation Mode (24 cases)
* Cases migrated from: run-app-basic.e2e.ts (Conversation mode describe block)
*
* Prerequisites (DIFY_E2E_* env vars):
* DIFY_E2E_CHAT_APP_ID — echo-chat app, mode=chat, outputs "echo: {query}"
*/
import type { AuthFixture } from '../../helpers/cli.js'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
assertErrorEnvelope,
assertExitCode,
assertJson,
assertPipeFriendlyJson,
assertStderrContains,
} from '../../helpers/assert.js'
import { registerConversation } from '../../helpers/cleanup-registry.js'
import { run, spawn_background, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
import { withRetry } from '../../helpers/retry.js'
import { optionalIt } from '../../helpers/skip.js'
import { loadE2EEnv } from '../../setup/env.js'
const E = loadE2EEnv()
const itWithSso = optionalIt(Boolean(E.ssoToken))
describe('E2E / difyctl run app --conversation', () => {
let fx: AuthFixture
beforeEach(async () => {
fx = await withAuthFixture(E)
})
afterEach(async () => {
await fx.cleanup()
})
// ── Create & reuse ──────────────────────────────────────────────────────
it('[P0] chat app can create a new conversation — stderr contains hint', async () => {
// Spec: chat app can create a new conversation
const result = await fx.r(['run', 'app', E.chatAppId, 'start-conv'])
assertExitCode(result, 0)
assertStderrContains(result, '--conversation')
})
it('[P0] JSON output includes conversation_id', async () => {
// Spec: JSON output includes conversation_id
const result = await fx.r(['run', 'app', E.chatAppId, 'conv-json', '-o', 'json'])
assertExitCode(result, 0)
const parsed = assertJson<{ conversation_id: string }>(result)
expect(typeof parsed.conversation_id).toBe('string')
expect(parsed.conversation_id.length).toBeGreaterThan(0)
registerConversation(E.host, E.token, E.chatAppId, parsed.conversation_id)
})
it('[P0] --conversation flag works — conversation_id is reused in subsequent requests', async () => {
// Spec: --conversation flag works; conversation_id is reused in subsequent requests
const first = await fx.r(['run', 'app', E.chatAppId, 'first-msg', '-o', 'json'])
assertExitCode(first, 0)
const { conversation_id } = assertJson<{ conversation_id: string }>(first)
registerConversation(E.host, E.token, E.chatAppId, conversation_id)
const second = await fx.r([
'run',
'app',
E.chatAppId,
'second-msg',
'--conversation',
conversation_id,
'-o',
'json',
])
assertExitCode(second, 0)
const secondParsed = assertJson<{ conversation_id: string }>(second)
expect(secondParsed.conversation_id).toBe(conversation_id)
})
it('[P0] a new session is auto-created when conversation_id is omitted', async () => {
// Spec 4.3.5: omitting --conversation creates a brand-new session each time;
// the new conversation_id must be non-empty and distinct from the previous one.
// withRetry: echo-chat app may return empty answer on back-to-back calls under load.
const firstId = await withRetry(async () => {
const r = await fx.r(['run', 'app', E.chatAppId, 'new-conv-1', '-o', 'json'])
assertExitCode(r, 0)
const { conversation_id } = assertJson<{ conversation_id: string }>(r)
expect(conversation_id, 'first call must return a non-empty conversation_id').toBeTruthy()
return conversation_id
}, { attempts: 3, delayMs: 2000 })
const secondId = await withRetry(async () => {
const r = await fx.r(['run', 'app', E.chatAppId, 'new-conv-2', '-o', 'json'])
assertExitCode(r, 0)
const { conversation_id } = assertJson<{ conversation_id: string }>(r)
expect(conversation_id, 'second call must return a non-empty conversation_id').toBeTruthy()
return conversation_id
}, { attempts: 3, delayMs: 2000 })
expect(secondId, 'omitting --conversation must create a new session, not reuse the previous one')
.not
.toBe(firstId)
})
// ── Error scenarios ─────────────────────────────────────────────────────
it('[P0] invalid conversation_id returns error (exit code 1)', async () => {
// Spec 4.3.9: passing a non-existent conversation_id should return a
// "conversation not found" error with exit code exactly 1.
const result = await fx.r([
'run',
'app',
E.chatAppId,
'bad-conv',
'--conversation',
'invalid-conv-id-xyz-not-exist',
])
assertExitCode(result, 1)
expect(result.stderr).toMatch(/not.?found|conversation|404/i)
})
// ── Combined flags ──────────────────────────────────────────────────────
it('[P1] conversation mode supports streaming', async () => {
// Spec 4.3.6: --conversation <cid> --stream should work and the streaming
// reply must carry the same conversation_id as the one used in the request.
// withRetry: echo-chat may return empty answer (no conversation_id) under load.
await withRetry(async () => {
const first = await fx.r(['run', 'app', E.chatAppId, 'init', '-o', 'json'])
assertExitCode(first, 0)
const { conversation_id } = assertJson<{ conversation_id: string }>(first)
expect(conversation_id, 'first call should return a conversation_id').toBeTruthy()
const result = await fx.r([
'run',
'app',
E.chatAppId,
'continue',
'--conversation',
conversation_id,
'--stream',
'-o',
'json',
])
assertExitCode(result, 0)
const streamed = assertJson<{ conversation_id?: string, answer?: string }>(result)
expect(streamed.conversation_id, 'streaming reply must carry the same conversation_id')
.toBe(conversation_id)
}, { attempts: 3, delayMs: 2000 })
})
it('[P1] conversation output supports piping (-o json pipe-friendly format)', async () => {
// Spec: conversation output supports piping
const result = await fx.r(['run', 'app', E.chatAppId, 'pipe-conv', '-o', 'json'])
assertExitCode(result, 0)
assertPipeFriendlyJson(result)
})
// ── Auth error scenarios ────────────────────────────────────────────────
it('[P0] unauthenticated conversation run returns auth error (exit code 4)', async () => {
// Spec 4.3.16: running --conversation without a valid session must return
// an authentication error with exit code exactly 4.
const unauthTmp = await withTempConfig()
try {
const result = await run(
['run', 'app', E.chatAppId, 'hello', '--conversation', 'any-conv-id'],
{ configDir: unauthTmp.configDir },
)
assertExitCode(result, 4)
}
finally {
await unauthTmp.cleanup()
}
})
itWithSso('[P0] SSO (dfoe_) token can run conversation mode (exit code 0)', async () => {
// Spec 4.3.17: an external SSO token (dfoe_) must be able to start a new
// conversation and receive a valid response; exit code must be 0.
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 })
const hostsYml = `${[
`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`
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await withRetry(
() => run(['run', 'app', E.chatAppId, 'sso-conv-test', '-o', 'json'], {
configDir: ssoTmp.configDir,
}),
{ attempts: 3, delayMs: 2000 },
)
assertExitCode(result, 0)
const parsed = assertJson<{ conversation_id?: string }>(result)
expect(parsed.conversation_id, 'SSO conversation run should return a conversation_id').toBeTruthy()
}
finally {
await ssoTmp.cleanup()
}
})
// ── P1 additions ────────────────────────────────────────────────────────
it('[P1] JSON output includes message_id field', async () => {
// Spec 4.3.15: -o json response must include a non-empty message_id field.
const result = await withRetry(async () => {
const r = await fx.r(['run', 'app', E.chatAppId, 'msg-id-check', '-o', 'json'])
assertExitCode(r, 0)
const parsed = assertJson<{ message_id?: string }>(r)
expect(parsed.message_id, 'message_id must be non-empty').toBeTruthy()
return r
}, { attempts: 3, delayMs: 2000 })
assertExitCode(result, 0)
})
it('[P1] after streaming interruption the same conversation_id remains usable', async () => {
// Spec 4.3.18: interrupting a streaming run must not corrupt the conversation;
// a subsequent non-streaming call with the same conversation_id must succeed.
const conversation_id = await withRetry(async () => {
const r = await fx.r(['run', 'app', E.chatAppId, 'pre-interrupt', '-o', 'json'])
assertExitCode(r, 0)
const { conversation_id: cid } = assertJson<{ conversation_id: string }>(r)
expect(cid, 'setup call must return a conversation_id').toBeTruthy()
return cid
}, { attempts: 3, delayMs: 2000 })
// Start a streaming run and interrupt it after 800 ms.
const proc = spawn_background(
['run', 'app', E.chatAppId, 'streaming-msg', '--conversation', conversation_id, '--stream'],
{ configDir: fx.configDir },
)
await new Promise(res => setTimeout(res, 800))
proc.interrupt()
await proc.wait()
// The conversation must still be usable after the interruption.
const resume = await withRetry(
() => fx.r([
'run',
'app',
E.chatAppId,
'after-interrupt',
'--conversation',
conversation_id,
'-o',
'json',
]),
{ attempts: 3, delayMs: 2000 },
)
assertExitCode(resume, 0)
const parsed = assertJson<{ conversation_id: string }>(resume)
expect(parsed.conversation_id, 'resumed call must carry the same conversation_id')
.toBe(conversation_id)
})
it('[P1] conversation run with unreachable host returns network error (exit non-zero)', async () => {
// Spec 4.3.19: when the configured host is unreachable, the CLI must return
// a network error with a non-zero exit code.
const { writeFile, mkdir } = await import('node:fs/promises')
const { join } = await import('node:path')
const networkTmp = await withTempConfig()
try {
await mkdir(networkTmp.configDir, { recursive: true })
const hostsYml = `${[
`current_host: http://127.0.0.1:19999`,
`token_storage: file`,
`tokens:`,
` bearer: dfoa_fake_token_network_test`,
`workspace:`,
` id: ${E.workspaceId}`,
` name: "E2E Test Workspace"`,
` role: owner`,
`available_workspaces:`,
` - id: ${E.workspaceId}`,
` name: "E2E Test Workspace"`,
` role: owner`,
].join('\n')}\n`
await writeFile(join(networkTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await run(
['run', 'app', E.chatAppId, 'hello', '--conversation', 'any-conv-id'],
{ configDir: networkTmp.configDir, timeout: 15_000 },
)
expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0)
expect(result.stderr.length, 'stderr should contain an error message').toBeGreaterThan(0)
}
finally {
await networkTmp.cleanup()
}
})
it('[P1] invalid conversation_id with -o json outputs JSON error envelope on stderr', async () => {
// Spec 4.3.22: when conversation_id is invalid and -o json is active,
// stderr must contain a valid JSON error envelope.
const result = await fx.r([
'run',
'app',
E.chatAppId,
'bad-conv-json',
'--conversation',
'nonexistent-conv-id-json-e2e',
'-o',
'json',
])
expect(result.exitCode, 'invalid conversation in json mode should exit non-zero').not.toBe(0)
assertErrorEnvelope(result)
})
it('[P1] passing --conversation to a workflow app does not crash (stable fallback)', async () => {
// Spec 4.3.23: workflow apps do not support conversations; the CLI must not
// crash. The server silently ignores the parameter and runs the workflow normally.
const result = await fx.r([
'run',
'app',
E.workflowAppId,
'--inputs',
JSON.stringify({ x: 'conv-wf-test', num: 1, enum_var: 'A', paragraph: 'ok' }),
'--conversation',
'any-conv-id-for-wf',
])
expect(result.exitCode, '--conversation on workflow must not cause an unhandled crash (exit 2)').not.toBe(2)
expect(result.stderr).not.toMatch(/unhandled|uncaught|TypeError|ReferenceError/i)
})
it('[P1] same conversation_id remains stable across 3 consecutive calls', async () => {
// Spec 4.3.24: reusing the same conversation_id multiple times must always
// succeed; each call must exit 0 and return the same conversation_id.
const conversation_id = await withRetry(async () => {
const r = await fx.r(['run', 'app', E.chatAppId, 'stable-1', '-o', 'json'])
assertExitCode(r, 0)
const { conversation_id: cid } = assertJson<{ conversation_id: string }>(r)
expect(cid, 'initial call must return a conversation_id').toBeTruthy()
return cid
}, { attempts: 3, delayMs: 2000 })
for (let i = 2; i <= 3; i++) {
const result = await withRetry(
() => fx.r([
'run',
'app',
E.chatAppId,
`stable-${i}`,
'--conversation',
conversation_id,
'-o',
'json',
]),
{ attempts: 3, delayMs: 2000 },
)
assertExitCode(result, 0)
const parsed = assertJson<{ conversation_id: string }>(result)
expect(parsed.conversation_id, `call ${i}: conversation_id must be stable`).toBe(conversation_id)
}
})
})

View File

@ -1,337 +0,0 @@
/**
* E2E: difyctl run app --file — file input specialisation
*
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Run/File Input (31 cases)
*
* Prerequisites:
* DIFY_E2E_FILE_APP_ID — workflow app with a required 'doc' file variable
* All file-related cases are skipped when this variable is not configured.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { assertExitCode, assertJson, assertNoAnsi } from '../../helpers/assert.js'
import { injectAuth, run, withTempConfig } from '../../helpers/cli.js'
import { withRetry } from '../../helpers/retry.js'
import { optionalDescribe, optionalIt } from '../../helpers/skip.js'
import { loadE2EEnv } from '../../setup/env.js'
const E = loadE2EEnv()
const itWithSso = optionalIt(Boolean(E.ssoToken))
// supportsLocalUpload capability removed — local file upload probe is no longer
// performed in global-setup. Default to false (skip upload-specific cases).
const supportsLocalUpload = true
const describeSuite = optionalDescribe(Boolean(E.fileAppId))
describeSuite('E2E / difyctl run app --file', () => {
let configDir: string
let fileDir: string
let cleanupConfig: () => Promise<void>
beforeEach(async () => {
const tmp = await withTempConfig()
configDir = tmp.configDir
cleanupConfig = tmp.cleanup
fileDir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-files-'))
await injectAuth(configDir, {
host: E.host,
bearer: E.token,
workspaceId: E.workspaceId,
workspaceName: E.workspaceName,
})
})
afterEach(async () => {
await cleanupConfig()
await rm(fileDir, { recursive: true, force: true })
})
function r(argv: string[]) {
return run(argv, { configDir })
}
// Minimal 1×1 white PNG — used as the required 'picture' (image) fixture.
async function writePng(path: string): Promise<void> {
const { Buffer } = await import('node:buffer')
const pngBytes = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI6QAAAABJRU5ErkJggg==',
'base64',
)
await writeFile(path, pngBytes)
}
const itLocalUpload = optionalIt(supportsLocalUpload)
itLocalUpload('[P0] run app supports single file upload (key=@path) — app executes correctly', async () => {
// Spec: run app supports single file upload + app executes correctly after upload
const filePath = join(fileDir, 'test.txt')
const picPath = join(fileDir, 'test.png')
await writeFile(filePath, 'E2E test file content — single upload')
await writePng(picPath)
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${filePath}`, '--file', `picture=@${picPath}`])
assertExitCode(result, 0)
})
itLocalUpload('[P0] file input argument name maps correctly (key binds to correct input field)', async () => {
// Spec: file input argument name maps correctly
const filePath = join(fileDir, 'mapping.txt')
const picPath = join(fileDir, 'mapping.png')
await writeFile(filePath, 'mapping test content')
await writePng(picPath)
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${filePath}`, '--file', `picture=@${picPath}`, '-o', 'json'])
assertExitCode(result, 0)
const parsed = assertJson<Record<string, unknown>>(result)
expect(parsed).toBeDefined()
})
itLocalUpload('[P0] run app --file syntax is key=@path (local file upload)', async () => {
// Spec: run app --file syntax is key=@path
const filePath = join(fileDir, 'syntax.txt')
const picPath = join(fileDir, 'syntax.png')
await writeFile(filePath, 'syntax verification')
await writePng(picPath)
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${filePath}`, '--file', `picture=@${picPath}`])
assertExitCode(result, 0)
})
it('[P0] --file remote URL syntax (key=https://...) requires no local upload', async () => {
// Spec: run app --file with remote URL executes the workflow correctly
// file_auto_test requires both 'doc' (document) and 'picture' (image) fields.
const result = await r([
'run',
'app',
E.fileAppId,
'--file',
'doc=https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf',
'--file',
'picture=https://www.w3.org/Icons/w3c_home.png',
])
assertExitCode(result, 0)
})
it('[P0] non-existent file path returns an error', async () => {
// Spec: non-existent file path returns an error
const result = await r([
'run',
'app',
E.fileAppId,
'--file',
'doc=@/nonexistent/path/missing-file.txt',
])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/failed|not.?found|upload|no such file|ENOENT/i)
})
it('[P1] malformed --file argument returns usage error (exit code 2)', async () => {
// Spec: malformed --file argument returns a usage error
const result = await r([
'run',
'app',
E.chatAppId,
'hello',
'--file',
'invalidformat',
])
assertExitCode(result, 2)
expect(result.stderr).toMatch(/--file|key[^\n\r@\u2028\u2029]*@.*path|invalid.*file/i)
})
itLocalUpload('[P1] file path containing spaces can be uploaded correctly', async () => {
// Spec: file path containing spaces can be uploaded correctly
const filePath = join(fileDir, 'file with spaces.txt')
const picPath = join(fileDir, 'pic spaces.png')
await writeFile(filePath, 'space in name test')
await writePng(picPath)
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${filePath}`, '--file', `picture=@${picPath}`])
assertExitCode(result, 0)
})
itLocalUpload('[P1] txt file upload is supported', async () => {
// Spec: txt file upload is supported
const f = join(fileDir, 'note.txt')
const picPath = join(fileDir, 'note.png')
await writeFile(f, 'plain text content')
await writePng(picPath)
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${f}`, '--file', `picture=@${picPath}`])
assertExitCode(result, 0)
})
itLocalUpload('[P1] --file combined with --stream works correctly', async () => {
// Spec: run app --file combined with --stream
const f = join(fileDir, 'stream.txt')
const picPath = join(fileDir, 'stream.png')
await writeFile(f, 'stream + file test')
await writePng(picPath)
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${f}`, '--file', `picture=@${picPath}`, '--stream'])
assertExitCode(result, 0)
})
it('[P0] unauthenticated file upload returns auth error (exit code 4)', async () => {
// Spec: unauthenticated file upload returns an auth error
const unauthTmp = await withTempConfig()
try {
const f = join(fileDir, 'unauth.txt')
await writeFile(f, 'test')
const result = await run(
['run', 'app', E.fileAppId || E.chatAppId, '--file', `doc=@${f}`],
{ configDir: unauthTmp.configDir },
)
assertExitCode(result, 4)
}
finally {
await unauthTmp.cleanup()
}
})
// ── P0 additions ────────────────────────────────────────────────────────
itLocalUpload('[P0] pdf file upload is supported (4.4.8)', async () => {
// Spec 4.4.8: .pdf is a valid document type for the doc field.
const pdfPath = join(fileDir, 'test.pdf')
const picPath = join(fileDir, 'pdf-pic.png')
await writeFile(pdfPath, '%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj '
+ '2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj '
+ '3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 3 3]>>endobj\n'
+ 'xref\n0 4\n0000000000 65535 f \n0000000009 00000 n \n'
+ '0000000058 00000 n \n0000000115 00000 n \n'
+ 'trailer<</Size 4/Root 1 0 R>>\nstartxref\n190\n%%EOF\n')
await writePng(picPath)
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${pdfPath}`, '--file', `picture=@${picPath}`])
assertExitCode(result, 0)
})
itWithSso('[P0] SSO (dfoe_) token can execute file run (exit code 0) (4.4.23)', async () => {
// Spec 4.4.23: an SSO-provisioned token must be able to run a file app.
// Note: DIFY_E2E_SSO_TOKEN may be a dfoa_ token in dev environments;
// the test verifies the token can execute the app regardless of prefix.
const { mkdir, writeFile: wf } = await import('node:fs/promises')
const { join: pjoin } = await import('node:path')
const ssoTmp = await withTempConfig()
try {
await mkdir(ssoTmp.configDir, { recursive: true })
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
`tokens:`,
` bearer: ${E.ssoToken}`,
`workspace:`,
` id: ${E.workspaceId}`,
` name: "E2E SSO Workspace"`,
` role: owner`,
`available_workspaces:`,
` - id: ${E.workspaceId}`,
` name: "E2E SSO Workspace"`,
` role: owner`,
].join('\n')}\n`
await wf(pjoin(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const docPath = pjoin(fileDir, 'sso-doc.txt')
const picPath = pjoin(fileDir, 'sso-pic.png')
await writeFile(docPath, 'sso file run test')
await writePng(picPath)
const result = await withRetry(
() => run(
['run', 'app', E.fileAppId, '--file', `doc=@${docPath}`, '--file', `picture=@${picPath}`],
{ configDir: ssoTmp.configDir },
),
{ attempts: 3, delayMs: 2000 },
)
assertExitCode(result, 0)
}
finally {
await ssoTmp.cleanup()
}
})
// ── P1 additions ────────────────────────────────────────────────────────
itLocalUpload('[P1] empty file upload returns stable result without crash (4.4.11)', async () => {
// Spec 4.4.11: uploading a zero-byte file must not crash the CLI (exit code != 2).
const emptyPath = join(fileDir, 'empty.txt')
const picPath = join(fileDir, 'empty-pic.png')
await writeFile(emptyPath, '')
await writePng(picPath)
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${emptyPath}`, '--file', `picture=@${picPath}`])
expect(result.exitCode, 'empty file must not cause CLI crash (exit 2)').not.toBe(2)
expect(result.stderr).not.toMatch(/unhandled|uncaught|TypeError|ReferenceError/i)
})
itLocalUpload('[P1] --file and --inputs flags can coexist (4.4.15 / 4.4.29)', async () => {
// Spec 4.4.15: passing both --file and --inputs must not cause a CLI error.
// Spec 4.4.29: workflow app accepts --inputs + --file together.
// file_auto_test has no non-file inputs; empty --inputs '{}' is passed to verify
// the CLI accepts both flags without a usage error.
const docPath = join(fileDir, 'inputs-doc.txt')
const picPath = join(fileDir, 'inputs-pic.png')
await writeFile(docPath, 'inputs + file coexist test')
await writePng(picPath)
const result = await r([
'run',
'app',
E.fileAppId,
'--inputs',
'{}',
'--file',
`doc=@${docPath}`,
'--file',
`picture=@${picPath}`,
])
expect(result.exitCode, '--inputs and --file together must not cause CLI usage error (exit 2)').not.toBe(2)
})
itLocalUpload('[P1] files with same name in different paths upload without conflict (4.4.16)', async () => {
// Spec 4.4.16: multiple --file entries with the same filename (different paths)
// must all upload successfully without collision.
const { mkdtemp: mkd } = await import('node:fs/promises')
const { tmpdir: td } = await import('node:os')
const dir2 = await mkd(join(td(), 'difyctl-e2e-samename-'))
try {
const docPath = join(fileDir, 'same.txt') // doc field
const picPath = join(dir2, 'same.png') // picture field — same base name, different dir
await writeFile(docPath, 'same name doc test')
await writePng(picPath)
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${docPath}`, '--file', `picture=@${picPath}`])
assertExitCode(result, 0)
}
finally {
const { rm: rmDir } = await import('node:fs/promises')
await rmDir(dir2, { recursive: true, force: true })
}
})
itLocalUpload('[P1] -o json after file upload contains workflow response fields (4.4.21)', async () => {
// Spec 4.4.21: -o json output after a file run must contain structured response metadata.
const docPath = join(fileDir, 'json-doc.txt')
const picPath = join(fileDir, 'json-pic.png')
await writeFile(docPath, 'json output test')
await writePng(picPath)
const result = await r([
'run',
'app',
E.fileAppId,
'--file',
`doc=@${docPath}`,
'--file',
`picture=@${picPath}`,
'-o',
'json',
])
assertExitCode(result, 0)
const parsed = assertJson<Record<string, unknown>>(result)
// workflow response must contain at minimum a mode field
expect(parsed.mode, 'JSON output must contain mode field').toBeTruthy()
assertNoAnsi(result.stdout, 'stdout')
})
itLocalUpload('[P1] file path with CJK characters uploads correctly (4.4.26)', async () => {
// Spec 4.4.26: a file whose path contains CJK (Chinese) characters must upload
// and execute successfully.
const cjkPath = join(fileDir, '中文测试文档.txt')
const picPath = join(fileDir, 'cjk-pic.png')
await writeFile(cjkPath, 'CJK path upload test — 中文内容')
await writePng(picPath)
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${cjkPath}`, '--file', `picture=@${picPath}`])
assertExitCode(result, 0)
})
})

View File

@ -1,142 +0,0 @@
/**
* E2E: difyctl run app + difyctl resume app — HITL human-in-the-loop specialisation
*
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Run/HITL Human Intervention (19 cases)
*
* Prerequisites:
* DIFY_E2E_HITL_APP_ID — workflow app containing a Human Input node with display_in_ui=true
* All HITL cases are skipped when this variable is not configured.
*/
import type { AuthFixture } from '../../helpers/cli.js'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { assertExitCode, assertJson, assertStderrContains } from '../../helpers/assert.js'
import { withAuthFixture } from '../../helpers/cli.js'
import { optionalDescribe } from '../../helpers/skip.js'
import { loadE2EEnv } from '../../setup/env.js'
const E = loadE2EEnv()
const describeSuite = optionalDescribe(Boolean(E.hitlAppId))
describeSuite('E2E / difyctl run app — HITL human intervention', () => {
let fx: AuthFixture
beforeEach(async () => {
fx = await withAuthFixture(E)
})
afterEach(async () => {
await fx.cleanup()
})
it('[P0] workflow HITL pause outputs a pause block on stdout — exit code 0', async () => {
// Spec: workflow HITL pause outputs a pause block + exit code 0
const result = await fx.r([
'run',
'app',
E.hitlAppId,
'--inputs',
JSON.stringify({ x: 'hitl-e2e' }),
])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/Workflow paused|pause/i)
})
it('[P0] HITL pause JSON contains all required fields', async () => {
// Spec: HITL pause JSON output contains all required fields
const result = await fx.r([
'run',
'app',
E.hitlAppId,
'--inputs',
JSON.stringify({ x: 'hitl-json' }),
'-o',
'json',
])
assertExitCode(result, 0)
const p = assertJson<Record<string, unknown>>(result)
expect(p).toHaveProperty('status', 'paused')
expect(p).toHaveProperty('form_token')
expect(p).toHaveProperty('workflow_run_id')
expect(p).toHaveProperty('node_title')
expect(p).toHaveProperty('form_content')
expect(p).toHaveProperty('actions')
})
it('[P0] HITL pause hint contains the full resume command', async () => {
// Spec: HITL pause hint contains the full resume command
const result = await fx.r([
'run',
'app',
E.hitlAppId,
'--inputs',
JSON.stringify({ x: 'hint-test' }),
])
assertExitCode(result, 0)
assertStderrContains(result, 'difyctl resume app')
assertStderrContains(result, '--workflow-run-id')
})
it('[P0] AI Agent automation — extract form_token from JSON and auto-resume', async () => {
// Spec: AI Agent automation — extract form_token via jq and auto-resume
// Step 1: run → pause, obtain JSON envelope
const pauseResult = await fx.r([
'run',
'app',
E.hitlAppId,
'--inputs',
JSON.stringify({ x: 'auto-resume' }),
'-o',
'json',
])
assertExitCode(pauseResult, 0)
const envelope = assertJson<{
form_token: string
workflow_run_id: string
app_id?: string
actions?: Array<{ id: string }>
}>(pauseResult)
expect(envelope.form_token).toBeTruthy()
expect(envelope.workflow_run_id).toBeTruthy()
// Step 2: resume — use the first action id from the pause response so
// the test is not coupled to any specific action label.
const actionId = envelope.actions?.[0]?.id ?? 'submit'
const resumeResult = await fx.r([
'resume',
'app',
E.hitlAppId,
envelope.form_token,
'--workflow-run-id',
envelope.workflow_run_id,
'--action',
actionId,
])
assertExitCode(resumeResult, 0)
})
it('[P0] resume app auto-selects the single action — workflow continues execution', async () => {
// Spec: resume app auto-selects the single action without requiring --action
const pause = await fx.r([
'run',
'app',
E.hitlAppId,
'--inputs',
JSON.stringify({ x: 'auto-action' }),
'-o',
'json',
])
assertExitCode(pause, 0)
const { form_token, workflow_run_id } = assertJson<{ form_token: string, workflow_run_id: string }>(pause)
// Resume without --action (single action auto-selected)
const resume = await fx.r([
'resume',
'app',
E.hitlAppId,
form_token,
'--workflow-run-id',
workflow_run_id,
])
assertExitCode(resume, 0)
})
})

View File

@ -1,340 +0,0 @@
/**
* E2E: difyctl run app --stream — streaming output specialisation
*
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Run/Streaming Output (24 cases)
*
* Covers scenarios that run-app-basic.e2e.ts cannot handle:
* - Ctrl+C interruption (SIGINT)
* - Chunk arrival order verification (timing)
* - Cases migrated from run-app-basic.e2e.ts: exit code, stderr separation,
* -o json envelope, unauthenticated, pipe, workflow succeeded status
*/
import type { Buffer } from 'node:buffer'
import type { AuthFixture } from '../../helpers/cli.js'
import { spawn } from 'node:child_process'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
assertErrorEnvelope,
assertExitCode,
assertJson,
assertNoAnsi,
assertStderrContains,
} from '../../helpers/assert.js'
import { BIN, BUN, run, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
import { withRetry } from '../../helpers/retry.js'
import { optionalIt } from '../../helpers/skip.js'
import { loadE2EEnv } from '../../setup/env.js'
const E = loadE2EEnv()
const itWithSso = optionalIt(Boolean(E.ssoToken))
describe('E2E / difyctl run app --stream (specialisation)', () => {
let fx: AuthFixture
beforeEach(async () => {
fx = await withAuthFixture(E)
})
afterEach(async () => {
await fx.cleanup()
})
// ── Chunk timing & token order ──────────────────────────────────────────
it('[P0] streaming output arrives in real-time chunks (stdout non-empty, echo complete)', async () => {
// Spec: streaming output is printed in real-time by chunk + token order is preserved
// withRetry: staging SSE connections may fail transiently on cold start
await withRetry(async () => {
const query = 'chunk-order-test'
const proc = spawn(BUN, [BIN, 'run', 'app', E.chatAppId, query, '--stream'], {
env: { ...process.env, DIFY_CONFIG_DIR: fx.configDir, CI: '1', NO_COLOR: '1' },
})
const chunks: string[] = []
proc.stdout.on('data', (d: Buffer) => {
chunks.push(d.toString('utf8'))
})
let stderr = ''
proc.stderr.on('data', (d: Buffer) => {
stderr += d.toString('utf8')
})
const exitCode = await new Promise<number>((res) => {
proc.on('close', code => res(code ?? 1))
})
assertExitCode({ stdout: chunks.join(''), stderr, exitCode }, 0)
// May arrive in multiple chunks; the concatenated result must contain the full query
expect(chunks.join('')).toContain(query)
}, { attempts: 3, delayMs: 2000 })
})
// ── Basic streaming behaviour ───────────────────────────────────────────
it('[P0] exit code is 0 after streaming completes', async () => {
// Spec: streaming exits normally after completion
const result = await fx.r(['run', 'app', E.chatAppId, 'end-ok', '--stream'])
assertExitCode(result, 0)
})
it('[P1] stderr is not mixed into stdout in streaming mode', async () => {
// Spec: stderr is not mixed into stdout in streaming mode
const result = await fx.r(['run', 'app', E.chatAppId, 'sep', '--stream'])
assertExitCode(result, 0)
expect(result.stdout).not.toContain('hint:')
assertStderrContains(result, '--conversation')
})
it('[P1] --stream -o json outputs a valid JSON envelope', async () => {
// Spec: streaming mode produces valid JSON output
const result = await fx.r(['run', 'app', E.chatAppId, 'sjson', '--stream', '-o', 'json'])
assertExitCode(result, 0)
const parsed = assertJson<{ mode: string, answer: string }>(result)
expect(parsed.mode).toMatch(/chat/)
})
it('[P1] streaming mode output supports piping (no ANSI, ends with \\n)', async () => {
// Spec: streaming mode output supports piping
const result = await fx.r(['run', 'app', E.chatAppId, 'pipe-s', '--stream'])
assertExitCode(result, 0)
assertNoAnsi(result.stdout, 'stdout')
expect(result.stdout.endsWith('\n')).toBe(true)
})
it('[P0] workflow streaming output contains succeeded status', async () => {
// Spec: workflow streaming output includes succeeded status
const result = await fx.r([
'run',
'app',
E.workflowAppId,
'--inputs',
JSON.stringify({ x: 'wf-stream-val' }),
'--stream',
'-o',
'json',
])
assertExitCode(result, 0)
const parsed = assertJson<{ data?: { status?: string } }>(result)
expect(parsed.data?.status).toBe('succeeded')
})
// ── Error scenarios ─────────────────────────────────────────────────────
it('[P0] server-side error event causes CLI to exit with non-zero code', async () => {
// Spec: server-side error event causes CLI to exit with non-zero code
// Use a non-existent app ID to force a server-side error.
const proc = spawn(BUN, [BIN, 'run', 'app', 'nonexistent-app-xyz-e2e', 'hi', '--stream'], {
env: { ...process.env, DIFY_CONFIG_DIR: fx.configDir, CI: '1', NO_COLOR: '1' },
})
let stderr = ''
proc.stderr.on('data', (d: Buffer) => {
stderr += d.toString('utf8')
})
const exitCode = await new Promise<number>((res) => {
proc.on('close', code => res(code ?? 1))
})
expect(exitCode, 'error event should cause non-zero exit').not.toBe(0)
expect(stderr.length).toBeGreaterThan(0)
})
it('[P0] unauthenticated streaming returns auth error (exit code 4)', async () => {
// Spec: unauthenticated streaming returns an auth error
const unauthTmp = await withTempConfig()
try {
const result = await run(['run', 'app', E.chatAppId, 'hi', '--stream'], {
configDir: unauthTmp.configDir,
})
assertExitCode(result, 4)
}
finally {
await unauthTmp.cleanup()
}
})
it('[P0] streaming fails when a required input is missing (exit code non-zero)', async () => {
// Spec: streaming fails when a required input is missing
// workflow app requires variable x (required); the server should return a validation error
// immediately, and the CLI exits with a non-zero code.
//
// ⚠️ Depends on feat/cli API version (server-side pre-validation of missing required inputs).
// Current local server 1.14.1 does not support this check; test passes once upgraded.
const proc = spawn(BUN, [BIN, 'run', 'app', E.workflowAppId, '--stream'], {
env: { ...process.env, DIFY_CONFIG_DIR: fx.configDir, CI: '1', NO_COLOR: '1' },
})
let stderr = ''
proc.stderr.on('data', (d: Buffer) => {
stderr += d.toString('utf8')
})
const exitCode = await new Promise<number>((res) => {
proc.on('close', code => res(code ?? 1))
})
expect(exitCode).not.toBe(0)
// The server should return a clear validation error rather than timing out
expect(stderr).toMatch(/validation|required|invalid|missing/i)
})
// ── SIGINT ──────────────────────────────────────────────────────────────
it('[P1] Ctrl+C interrupts streaming (SIGINT → non-zero exit code)', async () => {
// Spec: Ctrl+C interrupts streaming + exit code is non-zero after Ctrl+C
const proc = spawn(BUN, [BIN, 'run', 'app', E.chatAppId, 'ctrl-c-test', '--stream'], {
env: { ...process.env, DIFY_CONFIG_DIR: fx.configDir, CI: '1', NO_COLOR: '1' },
})
let _stdout = ''
let _stderr = ''
proc.stdout.on('data', (d: Buffer) => {
_stdout += d.toString('utf8')
})
proc.stderr.on('data', (d: Buffer) => {
_stderr += d.toString('utf8')
})
// Wait for the process to start streaming, then interrupt.
await new Promise(res => setTimeout(res, 800))
proc.kill('SIGINT')
const exitCode = await new Promise<number>((res) => {
proc.on('close', code => res(code ?? 1))
})
expect(exitCode, 'SIGINT should cause non-zero exit').not.toBe(0)
})
// ── Multiple inputs in streaming mode (4.2.8) ──────────────────────────
it('[P1] workflow streaming with multiple inputs passes all params correctly', async () => {
// Spec 4.2.8: multiple --inputs entries take effect simultaneously in streaming mode
const result = await withRetry(
() => fx.r([
'run',
'app',
E.workflowAppId,
'--inputs',
JSON.stringify({ x: 'multi-stream-k1', num: 42, enum_var: 'A', paragraph: 'short text' }),
'--stream',
'-o',
'json',
]),
{ attempts: 3, delayMs: 2000 },
)
assertExitCode(result, 0)
const parsed = assertJson<{ data?: { status?: string } }>(result)
expect(parsed.data?.status).toBe('succeeded')
})
// ── Unreachable host in streaming mode (4.2.13) ────────────────────────
it('[P0] streaming with unreachable host returns network error (exit code non-zero)', async () => {
// Spec 4.2.13: unreachable host → network error, exit code non-zero
// 127.0.0.1:19999 is a local port with nothing listening — ECONNREFUSED immediately.
const { writeFile, mkdir } = await import('node:fs/promises')
const { join } = await import('node:path')
const networkTmp = await withTempConfig()
try {
await mkdir(networkTmp.configDir, { recursive: true })
const hostsYml = `${[
`current_host: http://127.0.0.1:19999`,
`token_storage: file`,
`tokens:`,
` bearer: dfoa_fake_token_network_test`,
`workspace:`,
` id: ${E.workspaceId}`,
` name: "E2E Test Workspace"`,
` role: owner`,
`available_workspaces:`,
` - id: ${E.workspaceId}`,
` name: "E2E Test Workspace"`,
` role: owner`,
].join('\n')}\n`
await writeFile(join(networkTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await run(
['run', 'app', E.chatAppId, 'hello', '--stream'],
{ configDir: networkTmp.configDir, timeout: 15_000 },
)
expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0)
expect(result.stderr.length, 'stderr should contain error message').toBeGreaterThan(0)
}
finally {
await networkTmp.cleanup()
}
})
// ── Wrong-type input in streaming mode (4.2.15) ────────────────────────
it('[P0] streaming with wrong-type input returns validation error (exit code non-zero)', async () => {
// Spec 4.2.15: passing a value of the wrong type triggers server-side validation failure
// The workflow app expects `num` to be a number; passing a string should cause a validation error.
const result = await fx.r([
'run',
'app',
E.workflowAppId,
'--inputs',
JSON.stringify({ x: 'ok', num: 'not-a-number', enum_var: 'A', paragraph: 'short text' }),
'--stream',
])
expect(result.exitCode, 'wrong-type input should cause non-zero exit').not.toBe(0)
expect(result.stderr).toMatch(/validation|invalid|type|400|server_5xx|must be/i)
})
// ── Non-existent app with positional query (4.2.16) ────────────────────
it('[P0] streaming with non-existent app id and query exits 1 with app-not-found error', async () => {
// Spec 4.2.16: non-existent app id + positional query → app not found, exit code 1
// Distinct from the earlier server-error test: this checks exit=1 precisely and the not-found message.
const result = await fx.r(['run', 'app', 'nonexistent-app-id-404-streaming-e2e', 'hello', '--stream'])
expect(result.exitCode, 'app not found should exit with code 1').toBe(1)
expect(result.stderr).toMatch(/not.?found|404|does not exist/i)
})
// ── SSO (dfoe_) token in streaming mode (4.2.18) ──────────────────────
itWithSso('[P0] streaming with SSO (dfoe_) token succeeds (exit code 0, stdout non-empty)', async () => {
// Spec 4.2.18: dfoe_ token can invoke streaming run on an authorised app
const { writeFile, mkdir } = await import('node:fs/promises')
const { join } = await import('node:path')
const ssoTmp = await withTempConfig()
try {
await mkdir(ssoTmp.configDir, { recursive: true })
const hostsYml = `${[
`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`
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await withRetry(
() => run(['run', 'app', E.chatAppId, 'sso-stream-test', '--stream'], {
configDir: ssoTmp.configDir,
}),
{ attempts: 3, delayMs: 2000 },
)
assertExitCode(result, 0)
expect(result.stdout.length, 'SSO streaming should produce output').toBeGreaterThan(0)
}
finally {
await ssoTmp.cleanup()
}
})
// ── JSON error envelope for non-existent app in -o json mode (4.2.23) ─
it('[P1] non-existent app with --stream -o json outputs JSON error envelope on stderr', async () => {
// Spec 4.2.23: when app does not exist and -o json is set, stderr must be a valid JSON error envelope
const result = await fx.r([
'run',
'app',
'nonexistent-app-id-json-streaming-e2e',
'hello',
'--stream',
'-o',
'json',
])
expect(result.exitCode, 'should exit non-zero').not.toBe(0)
assertErrorEnvelope(result)
})
})

View File

@ -1,86 +0,0 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { defineConfig } from 'vite-plus'
import { resolveBuildInfo } from './scripts/lib/resolve-buildinfo.js'
const buildInfo = resolveBuildInfo()
// Load .env.e2e into process.env (only if the file exists; in CI vars are
// injected directly via GitHub Actions secrets).
const envFilePath = resolve(process.cwd(), '.env.e2e')
try {
const raw = readFileSync(envFilePath, 'utf8')
for (const line of raw.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#'))
continue
const eqIdx = trimmed.indexOf('=')
if (eqIdx === -1)
continue
const key = trimmed.slice(0, eqIdx).trim()
const val = trimmed.slice(eqIdx + 1).trim()
if (key && !(key in process.env))
process.env[key] = val
}
}
catch {
// .env.e2e not found — rely on environment variables already set in the shell
}
/**
* Vitest configuration for E2E tests.
*
* E2E tests run against a real staging Dify server and require
* DIFY_E2E_* environment variables to be set (see test/e2e/setup/env.ts).
*
* Run: bun vitest --config vitest.e2e.config.ts
*/
export default defineConfig({
pack: {
entry: ['src/index.ts'],
format: ['esm'],
outDir: 'dist',
target: 'node22',
define: {
__DIFYCTL_VERSION__: JSON.stringify(buildInfo.version),
__DIFYCTL_COMMIT__: JSON.stringify(buildInfo.commit),
__DIFYCTL_BUILD_DATE__: JSON.stringify(buildInfo.buildDate),
__DIFYCTL_CHANNEL__: JSON.stringify(buildInfo.channel),
__DIFYCTL_MIN_DIFY__: JSON.stringify(buildInfo.minDify),
__DIFYCTL_MAX_DIFY__: JSON.stringify(buildInfo.maxDify),
},
},
test: {
environment: 'node',
globalSetup: ['test/e2e/setup/global-setup.ts'],
// E2E tests do NOT use the unit-test setup.ts (no globalThis stubs needed —
// the real binary sets its own globals at startup).
setupFiles: [],
include: process.env.DIFY_E2E_MODE === 'local'
? ['test/e2e/suites/config/**/*.e2e.ts']
: [
// auth tests first (most others depend on a valid session)
'test/e2e/suites/auth/status.e2e.ts',
'test/e2e/suites/auth/use.e2e.ts',
'test/e2e/suites/auth/whoami.e2e.ts',
// config (local, no network)
'test/e2e/suites/config/**/*.e2e.ts',
// discovery (get app / describe app)
'test/e2e/suites/discovery/**/*.e2e.ts',
// run tests (require valid token)
'test/e2e/suites/run/**/*.e2e.ts',
// devices + logout LAST — both can revoke tokens
'test/e2e/suites/auth/devices.e2e.ts',
'test/e2e/suites/auth/logout.e2e.ts',
],
// E2E calls a real staging server — allow plenty of time per test.
testTimeout: 60_000,
hookTimeout: 30_000,
// Retry up to 2 times on staging flakiness.
retry: 0, // flaky tests use withRetry() locally; global retry masks non-idempotent failures
// Run suites sequentially to avoid workspace-level conflicts on staging.
pool: 'forks',
fileParallelism: false,
reporters: ['verbose'],
},
})

View File

@ -0,0 +1,25 @@
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
tmux \
&& rm -rf /var/lib/apt/lists/*
RUN python -m pip install --no-cache-dir \
shell-session-manager==2.1.1 \
uv
RUN useradd --create-home --shell /bin/sh dify
USER dify
WORKDIR /home/dify
EXPOSE 5004
CMD ["shellctl", "serve", "--listen", "0.0.0.0:5004"]

View File

@ -0,0 +1,195 @@
# Agent Run Lifecycle
This page explains, from a caller's perspective, how an `agent run` relates to a
`workflow run` and how callers control Agenton layer exit behavior with exit
signals.
## Relationship between agent runs and workflow runs
A `workflow run` is one full workflow execution. An `agent run` is one Agent
execution started by an Agent node while the workflow is running. They are not a
one-to-one mapping: one `workflow run` often contains multiple `agent run`s.
### First entry into an Agent node
When a `workflow run` first reaches an Agent node, the caller starts the first
`agent run` for that node.
The `agent run` enters the layers defined in its composition:
- If the request does not include `session_snapshot`, each layer enters with a
fresh state and initializes its own runtime state.
- If the request includes a previously returned `session_snapshot`, each layer
restores its runtime state from that snapshot and continues from there.
After entering layers, the Agent runs the LLM and tool calls until the current
`agent run` reaches a terminal result. This means the `agent run` has ended; it
does not necessarily mean the outer workflow has ended.
### Ending with a `final_output` tool call
If the Agent ends with a `final_output` tool call, the Agent node has produced
its final output for this pass. The caller should read the terminal output of the
current `agent run` and let the `workflow run` continue to downstream nodes.
The current `agent run` has ended, but the returned `session_snapshot` can still
be saved. If the same `workflow run` may enter the same Agent session again, the
caller should keep using that snapshot.
### Ending with a human tool call
If the Agent ends with a human tool call, the Agent needs human input before the
business process can continue. A common misconception is to treat this as a
paused agent run. **Agent runs do not have a pause state.** With a human tool, the
current `agent run` has ended; the outer `workflow run` is what should be paused.
The caller should handle this flow as follows:
1. Read the current `agent run` result and detect the HITL (human-in-the-loop)
requirement.
2. Enter workflow HITL handling and pause graphon.
3. Wait for the human input to be completed.
4. When resuming the workflow, insert the human tool response into the same Agent
session's history layer.
5. Start a second `agent run` on the same Agent node and reuse the same history
session.
In other words, a human tool does not mean “pause this agent run until it is
resumed.” It means “this agent run ended with a result that requires human
input.” After the caller completes HITL handling, it should create a new
`agent run` using the same history/session snapshot to continue.
### Entering another Agent node
When the same `workflow run` continues and reaches another Agent node, it starts
another `agent run`. That next Agent node may be a different Agent, or it may be
the same Agent reused by a roaster.
Therefore, callers should save and pass `session_snapshot` by Agent session, not
assume that one `workflow run` has only one `agent run`.
## Agent run exit signals
When an `agent run` ends, Dify Agent exits the layers that were entered by the
current run. Callers control whether each layer is suspended or deleted through
`CreateRunRequest.on_exit`.
Exit signals control the **layer lifecycle state**, not the execution state of an
`agent run`. The default policy is `suspend`, so a successful `agent run` returns
a reusable `session_snapshot`.
### Default: suspend layers
If a request does not explicitly set `on_exit`, it is equivalent to:
```json
{
"on_exit": {
"default": "suspend",
"layers": {}
}
}
```
This means every entered layer exits as `suspended` and is written into the
returned `session_snapshot`. The caller can submit that snapshot in the next
`agent run` to resume those layers.
For normal Agent execution inside a workflow, including both `final_output` and
human-tool endings, callers should keep the default suspend policy unless they
know the Agent session will never be resumed.
### Delete layers when the workflow run ends
When the whole `workflow run` has ended, the caller should start one more cleanup
`agent run`:
- Reuse the last available `session_snapshot`.
- Omit the LLM layer, because this run is only for entering and cleaning existing
state; it does not need to call the model again.
- Exit layers with the `delete` signal.
The cleanup request should use an exit signal like this:
```json
{
"on_exit": {
"default": "delete",
"layers": {}
}
}
```
After this run, the corresponding layers exit through the delete path. A snapshot
returned after deletion should not be used to resume the Agent session again.
### Override selected layers
The caller can also suspend by default while deleting only selected layers:
```json
{
"on_exit": {
"default": "suspend",
"layers": {
"temporary_context": "delete"
}
}
}
```
Only `temporary_context` exits with `delete`; all other active layers exit with
the default `suspend` behavior.
## Exit signal API reference
Fields related to exit control in `CreateRunRequest`:
| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `session_snapshot` | `CompositorSessionSnapshot \| None` | no | The session snapshot returned by the previous `agent run`. It resumes the same Agent session. |
| `on_exit` | `LayerExitSignals` | no | The exit policy used when this `agent run` exits layers. If omitted, all active layers are suspended by default. |
`LayerExitSignals` has this structure:
| Field | Type | Default | Meaning |
| --- | --- | --- | --- |
| `default` | `"suspend" \| "delete"` | `"suspend"` | Exit intent for layers not explicitly listed in `layers`. |
| `layers` | `dict[str, "suspend" \| "delete"]` | `{}` | Per-layer exit intent overrides by layer name. Each key must refer to a layer name in the current composition. |
Exit intent semantics:
| Exit intent | Layer exit state | Effect |
| --- | --- | --- |
| `suspend` | `suspended` | Keep the layer runtime state and make the returned `session_snapshot` usable by a later `agent run`. |
| `delete` | `closed` | Delete/close the layer context. The corresponding layer snapshot should not be resumed again. |
Python DTO example:
```python {test="skip" lint="skip"}
from agenton.layers import ExitIntent
from dify_agent.protocol import CreateRunRequest, LayerExitSignals
request = CreateRunRequest(
composition=composition,
session_snapshot=previous_snapshot,
on_exit=LayerExitSignals(
default=ExitIntent.SUSPEND,
layers={
"temporary_context": ExitIntent.DELETE,
},
),
)
```
Notes:
- `on_exit` only controls layer exit behavior; it does not cancel an `agent run`.
- Agent runs do not have a pause state. Human-tool waiting is handled by the
outer workflow/HITL flow.
- Keys in `on_exit.layers` must refer to layer names in the current composition.
- Use `suspend` and save the returned `session_snapshot` when the same Agent
session needs to continue later.
- After the whole `workflow run` ends, start one more cleanup run without an LLM
layer and use `delete`.

View File

@ -0,0 +1,202 @@
# Shell layer
The shell layer lets a Dify Agent run expose a `shellctl`-backed workspace to the
model. This page is for Dify Agent clients that build `CreateRunRequest`
payloads. It explains how to add the layer to a run composition and how the
server-side runtime must be wired.
The layer type id is `dify.shell`. Its public config is intentionally empty:
```python
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
from dify_agent.protocol import RunLayerSpec
RunLayerSpec(
name="shell",
type=DIFY_SHELL_LAYER_TYPE_ID,
config=DifyShellLayerConfig(),
)
```
Server-only settings, such as the `shellctl` HTTP entrypoint and auth token, are
injected by the Dify Agent runtime provider. They are not part of
`DifyShellLayerConfig` and should not be submitted by clients in the public run
request.
## Runtime requirements
When a run includes `dify.shell`, the Dify Agent server must construct its layer
providers with a non-empty shellctl entrypoint:
```python
from dify_agent.runtime.compositor_factory import create_default_layer_providers
layer_providers = create_default_layer_providers(
plugin_daemon_url="http://localhost:5002",
plugin_daemon_api_key="replace-with-plugin-daemon-key",
shellctl_entrypoint="http://127.0.0.1:5004",
shellctl_auth_token="replace-with-shellctl-token", # optional; defaults to no token
)
```
In the FastAPI server, these values are read from environment-backed
`ServerSettings` fields:
```env
DIFY_AGENT_SHELLCTL_ENTRYPOINT=http://127.0.0.1:5004
DIFY_AGENT_SHELLCTL_AUTH_TOKEN=replace-with-shellctl-token
```
`DIFY_AGENT_SHELLCTL_AUTH_TOKEN` defaults to `None`/empty, which keeps the shell
client on the no-token path. Set it only when the shellctl server is started with
bearer authentication.
## Client request shape
A client adds the shell layer as an ordinary composition layer. The shell layer
does not need dependencies. A typical run still also includes:
- a prompt layer that supplies the task;
- an execution-context layer carrying tenant/user context;
- an LLM layer named `llm`.
When clients want the shell workspace and shellctl job records to be removed at
the end of the run, set `on_exit.default` to `delete`.
## Example: CSV analysis run
The following example mirrors a verified run with a real Gemini model and a
temporary shellctl server. The client gives the model a small CSV-shaped dataset
and asks for computed metrics without prescribing the exact shell commands.
### Request
```python {test="skip" lint="skip"}
from agenton.layers import ExitIntent
from agenton_collections.layers.plain import PromptLayerConfig
from dify_agent.layers.dify_plugin.configs import DifyPluginLLMLayerConfig
from dify_agent.layers.execution_context import (
DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
DifyExecutionContextLayerConfig,
)
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID
from dify_agent.protocol.schemas import CreateRunRequest, LayerExitSignals, RunComposition, RunLayerSpec
request = CreateRunRequest(
composition=RunComposition(
layers=[
RunLayerSpec(
name="prompt",
type="plain.prompt",
config=PromptLayerConfig(
prefix="You are a practical data analyst. Give a concise final answer.",
user="""Analyze this small sales dataset with pandas. Use any local computation you think is useful.
region,product,units,unit_price
north,widget,12,3.50
north,gadget,5,9.00
south,widget,7,3.50
south,gadget,9,9.00
west,widget,4,3.50
west,gadget,11,9.00
Report the total revenue, the region with the most revenue, total units by
product, and a SHA-256 hash of the CSV content.""",
),
),
RunLayerSpec(
name="shell",
type=DIFY_SHELL_LAYER_TYPE_ID,
config=DifyShellLayerConfig(),
),
RunLayerSpec(
name="execution_context",
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
config=DifyExecutionContextLayerConfig(
tenant_id="92cca973-2d6f-45e0-906e-0b7eda5f2ccf",
invoke_from="workflow_run",
),
),
RunLayerSpec(
name=DIFY_AGENT_MODEL_LAYER_ID,
type="dify.plugin.llm",
deps={"execution_context": "execution_context"},
config=DifyPluginLLMLayerConfig(
plugin_id="langgenius/gemini",
model_provider="google",
model="gemini-3.5-flash",
credentials={"google_api_key": "<redacted>"},
),
),
]
),
on_exit=LayerExitSignals(default=ExitIntent.DELETE),
)
```
The same request serialized as JSON has these important layer entries:
```json
{
"composition": {
"schema_version": 1,
"layers": [
{"name": "prompt", "type": "plain.prompt"},
{"name": "shell", "type": "dify.shell", "config": {}},
{"name": "execution_context", "type": "dify.execution_context"},
{
"name": "llm",
"type": "dify.plugin.llm",
"deps": {"execution_context": "execution_context"}
}
]
},
"on_exit": {"default": "delete", "layers": {}}
}
```
### Final answer
The terminal `run_succeeded` output was:
````markdown
Here is the analysis of the sales dataset:
* **Total Revenue:** **$305.50**
* **Top Region:** **west** with **$113.00**
* **Total Units by Product:** gadget: 25 units, widget: 23 units
* **SHA-256 Hash:** `e86521a0d759037a09b059cb3cb2419f0a3f06e674db8151ccf2f93811dac0b8`
````
## Running shellctl in Docker
Build the shellctl image from the Dify Agent package root:
```bash
docker build -f docker/shellctl/Dockerfile -t dify-agent-shellctl:local .
```
Run it with a bearer token and publish the API on localhost:
```bash
docker run --rm --name dify-agent-shellctl \
-e SHELLCTL_AUTH_TOKEN=replace-with-a-token \
-p 127.0.0.1:5004:5004 \
dify-agent-shellctl:local
```
The image starts `shellctl serve --listen 0.0.0.0:5004` as the non-root
`dify` user and leaves shellctl state/runtime directories at their package
defaults.
## Docker image contents
The provided `docker/shellctl/Dockerfile` installs:
- `tmux`, required by `shellctl` to manage shell jobs;
- `shell-session-manager==2.1.1`, which provides the `shellctl` CLI/server;
- `uv`, so uv shebang scripts with PEP 723 metadata can run inside the shell
workspace;
- a non-root default user named `dify`.

View File

@ -14,10 +14,13 @@ nav:
- Examples: agenton/examples/index.md
- Dify Agent:
- Overview: dify-agent/index.md
- Concepts:
- Agent Run Lifecycle: dify-agent/concepts/run-lifecycle/index.md
- User Manual:
- Get Started: dify-agent/get-started/index.md
- Prompt Layer: dify-agent/user-manual/prompt-layer/index.md
- Execution Context Layer: dify-agent/user-manual/execution-context-layer/index.md
- Shell Layer: dify-agent/user-manual/shell-layer/index.md
- Plugin LLM Layer: dify-agent/user-manual/plugin-llm-layer/index.md
- Plugin Tool Layer: dify-agent/user-manual/plugin-tool-layer/index.md
- History Layer: dify-agent/user-manual/history-layer/index.md

View File

@ -19,6 +19,7 @@ server = [
"pydantic-ai-slim[anthropic,google,openai]>=1.85.1,<2.0.0",
"pydantic-settings>=2.12.0,<3.0.0",
"redis>=7.4.0,<8.0.0",
"shell-session-manager==2.1.1",
"uvicorn[standard]==0.46.0",
]

View File

@ -2,8 +2,9 @@
Agenton core composes reusable stateless layer graph plans, creates a fresh
``CompositorRun`` for each invocation, hydrates and advances serializable layer
``runtime_state`` through run slots, and emits session snapshots. It intentionally
does not own resources, handles, clients, cleanup callbacks, or any other
``runtime_state`` through run slots, enters each layer's active-scope
``resource_context()``, and emits session snapshots. It intentionally never
serializes resources, handles, clients, cleanup callbacks, or any other
non-serializable runtime object.
Each ``Compositor`` stores only graph nodes and layer providers. Every enter call

View File

@ -11,7 +11,8 @@ types.
``Compositor`` itself stores no live layer instances, run lifecycle state,
session state, resources, or handles. Each ``enter(...)`` call creates a fresh
``CompositorRun`` with new layer instances, direct dependency binding, optional
snapshot hydration, and the next ``session_snapshot`` after exit.
snapshot hydration, entered per-layer ``resource_context()`` scopes, and the
next ``session_snapshot`` after exit.
``LifecycleState.ACTIVE`` remains internal-only and session snapshots contain
only ordered layer lifecycle state plus serializable ``runtime_state``.

View File

@ -4,7 +4,8 @@
transformers. Each ``enter(...)`` call validates node-name keyed configs before
any provider factory runs, optionally validates and hydrates a session snapshot,
creates fresh layer instances, binds direct dependencies, and returns a new
``CompositorRun`` for that invocation only.
``CompositorRun`` for that invocation only. Dependency targets must point to
preceding graph nodes so resource scopes can nest in dependency order.
``Compositor.from_config(...)`` resolves serializable provider type ids rather
than import paths. Named ``node_providers`` override type-id providers for the
@ -49,8 +50,9 @@ class LayerNode:
``implementation`` may be a layer class or an explicit ``LayerProvider``.
``deps`` maps dependency field names on this node's layer class to other
compositor node names. ``metadata`` is graph description data only; it is
not passed to provider factories and is never included in session snapshots.
preceding compositor node names. ``metadata`` is graph description data
only; it is not passed to provider factories and is never included in
session snapshots.
"""
name: str
@ -89,7 +91,8 @@ class Compositor(Generic[PromptT, ToolT, LayerPromptT, LayerToolT, UserPromptT,
``tool_transformer`` are post-aggregation hooks on each run. Use two type
arguments for identity aggregation, four when prompt/tool layer item types
differ from exposed item types, or all six when user prompt item types also
differ.
differ. Graph order is meaningful for lifecycle nesting, so dependency edges
must point only to earlier nodes.
"""
__slots__ = ("_nodes", "prompt_transformer", "tool_transformer", "user_prompt_transformer")
@ -188,10 +191,14 @@ class Compositor(Generic[PromptT, ToolT, LayerPromptT, LayerToolT, UserPromptT,
"""
run = self._create_run(configs=configs, session_snapshot=session_snapshot)
await run._enter_layers()
body_error: BaseException | None = None
try:
yield run
except BaseException as exc:
body_error = exc
raise
finally:
await run._exit_layers()
await run._exit_layers(body_error)
def _create_run(
self,
@ -254,6 +261,8 @@ class Compositor(Generic[PromptT, ToolT, LayerPromptT, LayerToolT, UserPromptT,
raise ValueError(f"Duplicate layer name '{node.name}'.")
layer_names.add(node.name)
layer_index_by_name = {node.name: index for index, node in enumerate(self._nodes)}
for node in self._nodes:
declared_deps = node.provider.layer_type.dependency_names()
unknown_dep_keys = set(node.deps) - declared_deps
@ -265,6 +274,20 @@ class Compositor(Generic[PromptT, ToolT, LayerPromptT, LayerToolT, UserPromptT,
names = ", ".join(sorted(missing_targets))
raise ValueError(f"Layer '{node.name}' depends on undefined layer names: {names}.")
non_preceding_targets = {
dep_name: target_name
for dep_name, target_name in node.deps.items()
if layer_index_by_name[target_name] >= layer_index_by_name[node.name]
}
if non_preceding_targets:
targets = ", ".join(
f"{dep_name}->{target_name}" for dep_name, target_name in sorted(non_preceding_targets.items())
)
raise ValueError(
f"Layer '{node.name}' dependencies must target preceding layer nodes in compositor order: "
f"{targets}."
)
def _validate_run_configs(self, configs: Mapping[str, LayerConfigInput] | None) -> dict[str, LayerConfigInput]:
config_by_name = dict(configs or {})
known_names = {node.name for node in self._nodes}

View File

@ -1,11 +1,18 @@
"""Active compositor run lifecycle, snapshots, and aggregation.
``CompositorRun`` is the only compositor object that exposes live layer
instances. It owns invocation-local lifecycle state, per-layer exit intent, and
the next ``session_snapshot`` after exit. Layers enter in graph order and exit
in reverse graph order. Prompt aggregation preserves graph ordering: prefix
prompts first-to-last, suffix prompts last-to-first, user prompts first-to-last,
and tools in graph order.
instances. It owns invocation-local lifecycle state, per-layer exit intent,
entered layer resource scopes, and the next ``session_snapshot`` after exit.
Layers enter in graph order and exit in reverse graph order. A layer's
``resource_context()`` wraps that layer's enter hook, the active run body while
the layer remains active, and the layer's exit hook. Prompt aggregation
preserves graph ordering: prefix prompts first-to-last, suffix prompts
last-to-first, user prompts first-to-last, and tools in graph order.
Enter hooks transition a slot to ``LifecycleState.ACTIVE`` only after returning
successfully. If ``on_context_create`` or ``on_context_resume`` raises, the run
still exits any already-entered resource contexts, but it does not call the
layer's normal suspend/delete hook for that failed enter attempt.
Prompt, user prompt, and tool transformers run only after layer-level wrapping
and run-level aggregation. When no transformer is installed, the wrapped items
@ -14,6 +21,7 @@ are returned unchanged.
from collections import OrderedDict
from collections.abc import Sequence
from contextlib import AbstractAsyncContextManager
from dataclasses import dataclass
from typing import Any, Generic, cast, overload
@ -36,11 +44,12 @@ from .types import (
@dataclass(slots=True)
class LayerRunSlot:
"""Invocation-local lifecycle and exit state for one fresh layer instance."""
"""Invocation-local lifecycle, resource scope, and exit state for one layer."""
layer: Layer[Any, Any, Any, Any, Any, Any]
lifecycle_state: LifecycleState
exit_intent: ExitIntent = ExitIntent.DELETE
active_resource_context: AbstractAsyncContextManager[None] | None = None
@dataclass(slots=True)
@ -123,40 +132,57 @@ class CompositorRun(Generic[PromptT, ToolT, LayerPromptT, LayerToolT, UserPrompt
await self._enter_slot(slot)
entered_slots.append(slot)
except BaseException as enter_error:
hook_error = await self._exit_slots_reversed(entered_slots)
hook_error = await self._exit_slots_reversed(entered_slots, wrapped_error=enter_error)
self.session_snapshot = self.snapshot_session()
if hook_error is not None:
raise hook_error from enter_error
raise
async def _exit_layers(self) -> None:
hook_error = await self._exit_slots_reversed(list(self.slots.values()))
async def _exit_layers(self, wrapped_error: BaseException | None = None) -> None:
hook_error = await self._exit_slots_reversed(list(self.slots.values()), wrapped_error=wrapped_error)
self.session_snapshot = self.snapshot_session()
if hook_error is not None:
raise hook_error
async def _enter_slot(self, slot: LayerRunSlot) -> None:
if slot.lifecycle_state is LifecycleState.NEW:
slot.exit_intent = ExitIntent.DELETE
await slot.layer.on_context_create()
slot.lifecycle_state = LifecycleState.ACTIVE
return
if slot.lifecycle_state is LifecycleState.SUSPENDED:
slot.exit_intent = ExitIntent.DELETE
await slot.layer.on_context_resume()
slot.lifecycle_state = LifecycleState.ACTIVE
return
raise RuntimeError(f"Cannot enter layer from lifecycle state '{slot.lifecycle_state}'.")
resource_context = slot.layer.resource_context()
await resource_context.__aenter__()
slot.active_resource_context = resource_context
try:
if slot.lifecycle_state is LifecycleState.NEW:
slot.exit_intent = ExitIntent.DELETE
await slot.layer.on_context_create()
slot.lifecycle_state = LifecycleState.ACTIVE
return
if slot.lifecycle_state is LifecycleState.SUSPENDED:
slot.exit_intent = ExitIntent.DELETE
await slot.layer.on_context_resume()
slot.lifecycle_state = LifecycleState.ACTIVE
return
raise RuntimeError(f"Cannot enter layer from lifecycle state '{slot.lifecycle_state}'.")
except BaseException as enter_error:
resource_error = await self._exit_resource_context(slot, wrapped_error=enter_error)
if resource_error is not None:
raise resource_error from enter_error
raise
async def _exit_slots_reversed(self, slots: Sequence[LayerRunSlot]) -> BaseException | None:
async def _exit_slots_reversed(
self,
slots: Sequence[LayerRunSlot],
*,
wrapped_error: BaseException | None = None,
) -> BaseException | None:
hook_error: BaseException | None = None
propagating_error = wrapped_error
for slot in reversed(slots):
if slot.lifecycle_state is not LifecycleState.ACTIVE:
continue
slot_error: BaseException | None = None
if slot.exit_intent is ExitIntent.SUSPEND:
try:
await slot.layer.on_context_suspend()
except BaseException as exc:
slot_error = exc
hook_error = hook_error or exc
finally:
slot.lifecycle_state = LifecycleState.SUSPENDED
@ -164,12 +190,43 @@ class CompositorRun(Generic[PromptT, ToolT, LayerPromptT, LayerToolT, UserPrompt
try:
await slot.layer.on_context_delete()
except BaseException as exc:
slot_error = exc
hook_error = hook_error or exc
finally:
slot.lifecycle_state = LifecycleState.CLOSED
slot_scope_error = slot_error or propagating_error
resource_error = await self._exit_resource_context(slot, wrapped_error=slot_scope_error)
if resource_error is not None:
hook_error = hook_error or resource_error
propagating_error = resource_error
continue
propagating_error = slot_scope_error
return hook_error
async def _exit_resource_context(
self,
slot: LayerRunSlot,
*,
wrapped_error: BaseException | None,
) -> BaseException | None:
resource_context = slot.active_resource_context
if resource_context is None:
return None
slot.active_resource_context = None
exc_type = type(wrapped_error) if wrapped_error is not None else None
exc_traceback = wrapped_error.__traceback__ if wrapped_error is not None else None
try:
# Resource scopes exist for deterministic live-resource cleanup. They
# should observe the exception leaving the wrapped scope, but Agenton
# still preserves its own hook/body error propagation rules.
await resource_context.__aexit__(exc_type, wrapped_error, exc_traceback)
except BaseException as exc:
return exc
return None
def _set_layer_exit_intent(self, name: str, intent: ExitIntent) -> None:
try:
slot = self.slots[name]

View File

@ -4,6 +4,8 @@ Graph config and session snapshots are separate boundaries on purpose. Graph
config describes only reusable composition state: schema version, ordered node
names, provider type ids, dependency mappings, and metadata. Session snapshots
carry only ordered layer lifecycle state plus serializable ``runtime_state``.
Live resources acquired inside ``Layer.resource_context()`` are active-scope
only and can never appear in these DTOs.
External DTOs are revalidated even when callers pass an already-constructed
Pydantic model instance. These models are mutable, so dumping and validating
@ -99,8 +101,9 @@ class CompositorSessionSnapshot(BaseModel):
"""Serializable compositor session snapshot.
Snapshots include ordered layer lifecycle state and serializable runtime
state only. Live resources, handles, dependencies, prompts, tools, and
config are outside Agenton snapshots and are never captured here.
state only. Live resources from ``Layer.resource_context()``, handles,
dependencies, prompts, tools, and config are outside Agenton snapshots and
are never captured here.
"""
schema_version: int = 1

View File

@ -1,10 +1,11 @@
"""Invocation-scoped core layer abstractions and typed dependency binding.
Agenton core deliberately manages only three concerns: stateless layer graph
composition, serializable ``runtime_state`` lifecycle, and session snapshots. It
does not own live resources, process handles, HTTP clients, cleanup stacks, or
any other non-serializable runtime object. Those belong to application layers or
integration code outside the core.
Agenton core deliberately manages four concerns: stateless layer graph
composition, serializable ``runtime_state`` lifecycle, per-active-invocation
resource scopes, and session snapshots. Live resources remain layer-owned:
Agenton may enter ``Layer.resource_context()`` for the active scope, but it
never serializes or snapshots clients, process handles, cleanup stacks, or any
other non-serializable runtime object.
Layers declare their dependency shape with
``Layer[DepsT, PromptT, UserPromptT, ToolT, ConfigT, RuntimeStateT]``.
@ -24,18 +25,27 @@ when possible, while still allowing subclasses to set them explicitly for
unusual inheritance patterns.
``Layer`` is an invocation-scoped business object. It owns ``config``, direct
``deps``, and serializable ``runtime_state`` plus prompt/tool authoring surfaces,
but it does not own lifecycle state, exit intent, graph owner tokens, entry
stacks, resources, or cleanup callbacks. ``CompositorRun`` owns lifecycle state
and exit intent for one entry. ``SessionSnapshot`` objects are the only supported
cross-call state carrier.
``deps``, serializable ``runtime_state``, prompt/tool authoring surfaces, and
any live resource fields managed by ``resource_context()``. It does not own
lifecycle state, exit intent, graph owner tokens, or entry stacks.
``CompositorRun`` owns lifecycle state and exit intent for one entry and
orchestrates entering and exiting each layer's resource scope. ``SessionSnapshot``
objects are the only supported cross-call state carrier.
Lifecycle hooks are no-argument business hooks on the layer instance:
``on_context_create/resume/suspend/delete(self)``. They should read dependencies
from ``self.deps`` and read or mutate serializable invocation state through
``self.runtime_state``. Resource acquisition and deterministic cleanup should be
handled outside Agenton core, for example by integration-specific context
managers that wrap compositor entry.
``self.runtime_state``. ``resource_context(self)`` is the symmetric active-scope
API for live resources. Agenton enters it before ``on_context_create`` or
``on_context_resume`` and exits it after ``on_context_suspend`` or
``on_context_delete``. Create-versus-resume differences stay in the business
hooks; ``resource_context`` should manage only live resource setup and cleanup.
Agenton marks a slot ``ACTIVE`` only after ``on_context_create`` or
``on_context_resume`` returns successfully. If either enter hook raises, normal
``on_context_suspend``/``on_context_delete`` hooks do not run for that failed
attempt. Enter hooks therefore own any business compensation or idempotency for
partial side effects, while Agenton guarantees only ``resource_context()``
cleanup, not hook rollback.
``Layer`` is framework-neutral over system prompt, user prompt, and tool item
types. The native ``prefix_prompts``, ``suffix_prompts``, ``user_prompts``, and
@ -47,11 +57,13 @@ native values without changing layer implementations.
from abc import ABC, abstractmethod
from collections.abc import Mapping, Sequence
from contextlib import asynccontextmanager
from dataclasses import dataclass
from enum import StrEnum
from types import UnionType
from typing import (
Any,
AsyncIterator,
ClassVar,
Generic,
Union,
@ -183,10 +195,11 @@ class Layer(
snapshot, and then runs no-argument lifecycle hooks. The run owns lifecycle
state and exit intent; layers never expose a public entry context manager.
Live resources and handles are intentionally outside this abstraction. Only
``runtime_state`` is managed and snapshotted by Agenton core. Lifecycle hooks
should operate on ``self`` and keep any non-serializable cleanup policy in
integration code that wraps the compositor.
``runtime_state`` is the only mutable data Agenton snapshots across calls.
Live resources belong on the layer instance itself and should be acquired in
``resource_context()``. Agenton keeps that resource scope active while the
corresponding enter hook, run body, and exit hook execute, then tears it
down deterministically even when later hooks or the body fail.
"""
deps_type: type[_DepsT]
@ -267,17 +280,46 @@ class Layer(
resolved_deps[name] = deps[name]
self.deps = self.deps_type(**resolved_deps)
@asynccontextmanager
async def resource_context(self) -> AsyncIterator[None]:
"""Wrap one active invocation with live non-serializable resources.
Agenton enters this no-argument context before ``on_context_create`` or
``on_context_resume`` and exits it after ``on_context_suspend`` or
``on_context_delete``. Use it for live clients, process handles, or
other non-serializable objects stored on ``self``. Keep create-versus-
resume business differences in the corresponding lifecycle hooks.
"""
yield
async def on_context_create(self) -> None:
"""Run when the run slot enters from ``LifecycleState.NEW``."""
"""Run when the run slot enters from ``LifecycleState.NEW``.
``resource_context()`` is already active for this layer when this hook
runs. If this hook raises, the layer never becomes ``ACTIVE`` and no
normal ``on_context_delete()`` hook runs for that failed enter attempt.
"""
async def on_context_delete(self) -> None:
"""Run when the run slot exits with ``ExitIntent.DELETE``."""
"""Run when the run slot exits with ``ExitIntent.DELETE``.
``resource_context()`` remains active while this hook runs.
"""
async def on_context_suspend(self) -> None:
"""Run when the run slot exits with ``ExitIntent.SUSPEND``."""
"""Run when the run slot exits with ``ExitIntent.SUSPEND``.
``resource_context()`` remains active while this hook runs.
"""
async def on_context_resume(self) -> None:
"""Run when the run slot enters from ``LifecycleState.SUSPENDED``."""
"""Run when the run slot enters from ``LifecycleState.SUSPENDED``.
``resource_context()`` is already active for this layer when this hook
runs. If this hook raises, the layer never becomes ``ACTIVE`` and no
normal ``on_context_suspend()`` or ``on_context_delete()`` hook runs for
that failed resume attempt.
"""
@property
def prefix_prompts(self) -> Sequence[_PromptT]:

View File

@ -0,0 +1,10 @@
"""Client-safe exports for the Dify shell layer DTOs.
The runtime layer implementation lives in ``layer.py`` and imports shellctl
client code plus server-side lifecycle behavior. Keep this package root
import-safe for client code that only needs to build run requests.
"""
from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
__all__ = ["DIFY_SHELL_LAYER_TYPE_ID", "DifyShellLayerConfig"]

View File

@ -0,0 +1,25 @@
"""Client-safe DTOs for the Dify shell Agenton layer.
This first shell layer version intentionally has no public configuration beyond
its stable type id. Server-only shellctl connection settings are injected by the
runtime provider factory so client code cannot accidentally depend on process
environment or transport details.
"""
from typing import ClassVar, Final
from pydantic import ConfigDict
from agenton.layers import LayerConfig
DIFY_SHELL_LAYER_TYPE_ID: Final[str] = "dify.shell"
class DifyShellLayerConfig(LayerConfig):
"""Empty public config for the shellctl-backed Dify shell layer."""
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
__all__ = ["DIFY_SHELL_LAYER_TYPE_ID", "DifyShellLayerConfig"]

View File

@ -0,0 +1,733 @@
"""Shellctl-backed Dify shell layer.
``DifyShellLayer`` is a stateful pydantic-ai tool layer that exposes exactly
``shell.run``, ``shell.wait``, ``shell.input``, and ``shell.interrupt``. The
layer persists only JSON-safe shell session state in ``runtime_state`` and keeps
its live shellctl HTTP client on the layer instance only while
``resource_context()`` is active. Agenton enters that resource scope before
``on_context_create`` or ``on_context_resume`` and exits it after
``on_context_suspend`` or ``on_context_delete``, so business hooks and shell
tools can rely on a live client without ever serializing it into snapshots.
The runtime state tracks shellctl job ids for both user-visible shell jobs and
internal lifecycle jobs such as workspace mkdir/cleanup commands. Those internal
jobs are intentionally not deleted ad hoc; shellctl job-state deletion is
centralized in ``on_context_delete`` so one lifecycle hook owns exit-time
cleanup for successful create/resume flows. If ``on_context_create`` or a later
side-effecting ``on_context_resume`` attempt fails after issuing shellctl jobs,
Agenton still exits ``resource_context()`` but never transitions the layer to
``ACTIVE``. In that failed-enter path, normal suspend/delete hooks do not run,
so the enter hook itself must perform best-effort business compensation before
re-raising the failure.
"""
from __future__ import annotations
from collections.abc import AsyncGenerator, Callable, Sequence
from contextlib import asynccontextmanager
import logging
import re
import secrets
import time
from dataclasses import dataclass
from typing import ClassVar, NotRequired, Protocol, TypedDict
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, field_validator, model_validator
from pydantic_ai import Tool
from shell_session_manager.shellctl.client import ShellctlClient, ShellctlClientError
from shell_session_manager.shellctl.shared import (
DEFAULT_TERMINATE_GRACE_SECONDS,
DEFAULT_TIMEOUT_SECONDS,
DeleteJobResponse,
JobResult,
JobStatusView,
)
from typing_extensions import Self, override
from agenton.layers import NoLayerDeps, PydanticAILayer, PydanticAIPrompt, PydanticAITool
from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
logger = logging.getLogger(__name__)
_WORKSPACE_ROOT = "~/workspace"
_WORKSPACE_COLLISION_EXIT_CODE = 17
_SESSION_TIME_HEX_MASK = 0xFFFFF
_SESSION_RANDOM_HEX_LENGTH = 2
_SESSION_ID_ATTEMPT_LIMIT = 256
_SESSION_ID_PATTERN = re.compile(r"^[0-9a-f]{7}$")
_SHELL_LAYER_PREFIX_PROMPT = """You have access to a shell layer. It provides four tools:
1. shell.run
Start a new shell job in the current isolated workspace.
Use it to execute commands or scripts.
2. shell.wait
Wait for more output or completion from an existing shell job.
Use it when shell.run returns done=false.
3. shell.input
Send stdin text to a running shell job, then wait for new output.
Use it for interactive commands that are waiting for input.
4. shell.interrupt
Interrupt a running shell job.
Use it to stop a long-running, stuck, or no-longer-needed command.
Common arguments:
- script:
The command or script to execute. Used by shell.run.
- job_id:
The id of a shell job returned by shell.run.
Use it with shell.wait, shell.input, and shell.interrupt.
Never invent a job_id.
- timeout:
Maximum time, in seconds, to wait for output or completion for this tool call.
A timeout does not necessarily mean the job has stopped; if done=false, use shell.wait again.
- text:
Text to send to the running process stdin. Used by shell.input.
Include "\\n" if the process expects Enter.
- grace_seconds:
Time to wait after interrupting before forceful cleanup. Used by shell.interrupt.
Usage rules:
- Start with shell.run.
- If shell.run returns done=false, call shell.wait with the returned job_id.
- Use shell.input only when the job is running and waiting for stdin.
- Use shell.interrupt when a job is stuck or should be stopped.
The script argument of shell.run can be a normal shell script, or a shebang script.
If the first line is a shebang, the shell layer executes the script directly.
Tips:
- When using Python, prefer a uv script with a PEP 723 dependency header.
Example:
#!/usr/bin/env -S uv run --quiet --script
# /// script
# requires-python = ">=3.12"
# dependencies = [
# "httpx==0.28.1",
# "rich>=13.8.0",
# ]
# ///
import httpx
from rich import print
response = httpx.get("https://example.com", timeout=10)
print(f"[green]status:[/green] {response.status_code}")"""
class ShellJobObservation(TypedDict):
"""JSON-safe output-oriented shell tool observation."""
job_id: str
status: str
done: bool
exit_code: int | None
output: str
offset: int
truncated: bool
output_path: str
class ShellJobStatusObservation(TypedDict):
"""JSON-safe status-only shell tool observation."""
job_id: str
status: str
done: bool
exit_code: int | None
offset: int
class ShellToolErrorObservation(TypedDict):
"""Tool-visible failure payload for expected shell-layer errors."""
error: str
job_id: NotRequired[str]
type ShellRunToolResult = ShellJobObservation | ShellToolErrorObservation
type ShellInterruptToolResult = ShellJobStatusObservation | ShellToolErrorObservation
class ShellctlClientProtocol(Protocol):
"""Boundary that the shell layer needs from a shellctl client."""
async def run(
self,
script: str,
*,
cwd: str | None = None,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
) -> JobResult: ...
async def wait(
self,
job_id: str,
*,
offset: int,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
) -> JobResult: ...
async def input(
self,
job_id: str,
text: str,
*,
offset: int,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
) -> JobResult: ...
async def terminate(
self,
job_id: str,
grace_seconds: float = DEFAULT_TERMINATE_GRACE_SECONDS,
) -> JobStatusView: ...
async def delete(
self,
job_id: str,
*,
force: bool = False,
grace_seconds: float | None = None,
) -> DeleteJobResponse: ...
async def close(self) -> None: ...
type ShellctlClientFactory = Callable[[str], ShellctlClientProtocol]
class DifyShellRuntimeState(BaseModel):
"""Serializable shell session state stored in Agenton snapshots.
``job_ids`` and ``job_offsets`` contain both user-facing jobs and internal
lifecycle jobs so resumed sessions can still clean up shellctl state that was
created before suspension. Callers should replace the stored list/dict values
rather than mutating them in place so Pydantic assignment validation keeps
guarding the serialized state. Hydrated public snapshots must keep
``session_id`` in the proposal's safe lowercase-hex format and must keep
``workspace_cwd`` exactly aligned with ``~/workspace/<session_id>`` so resume
and delete paths cannot escape the isolated workspace root or inject shell
syntax into lifecycle commands. Shellctl job ids remain opaque strings here;
the layer only enforces uniqueness plus the invariant that any stored offset
entry must belong to a tracked job id in the same runtime state.
"""
session_id: str | None = None
workspace_cwd: str | None = None
job_ids: list[str] = Field(default_factory=list)
job_offsets: dict[str, NonNegativeInt] = Field(default_factory=dict)
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", validate_assignment=True)
@field_validator("session_id")
@classmethod
def validate_session_id(cls, value: str | None) -> str | None:
"""Accept only the short lowercase-hex session ids defined by the proposal."""
if value is None:
return value
return _validated_session_id(value)
@field_validator("job_ids")
@classmethod
def validate_job_ids(cls, value: list[str]) -> list[str]:
"""Keep tracked shellctl job ids unique within one serialized session."""
if len(value) != len(set(value)):
raise ValueError("job_ids must not contain duplicates.")
return value
@model_validator(mode="after")
def validate_workspace_and_offsets(self) -> Self:
"""Keep resumed workspace identity and tracked offset keys self-consistent."""
if self.workspace_cwd is not None:
if self.session_id is None:
raise ValueError("workspace_cwd requires a matching session_id.")
expected_workspace = _workspace_cwd(self.session_id)
if self.workspace_cwd != expected_workspace:
raise ValueError(
f"workspace_cwd must equal {expected_workspace!r} for session_id {self.session_id!r}."
)
unknown_offset_job_ids = set(self.job_offsets) - set(self.job_ids)
if unknown_offset_job_ids:
names = ", ".join(sorted(unknown_offset_job_ids))
raise ValueError(f"job_offsets contains unknown job ids: {names}.")
return self
@dataclass(slots=True)
class DifyShellLayer(PydanticAILayer[NoLayerDeps, object, DifyShellLayerConfig, DifyShellRuntimeState]):
"""Shell tool layer backed by a live shellctl client while active.
The mutable serializable state lives in ``runtime_state``; the live client is
intentionally kept off-snapshot in ``_shellctl_client``. Tool methods update
tracked job ids and output offsets after every successful shellctl response so
later ``shell.wait``/``shell.input`` calls can resume from the last known
offset without exposing offsets as model-controlled inputs.
"""
type_id: ClassVar[str | None] = DIFY_SHELL_LAYER_TYPE_ID
config: DifyShellLayerConfig
shellctl_entrypoint: str
shellctl_client_factory: ShellctlClientFactory
_shellctl_client: ShellctlClientProtocol | None = None
@classmethod
@override
def from_config(cls, config: DifyShellLayerConfig) -> Self:
"""Reject construction that omits server-injected shellctl settings."""
del config
raise TypeError("DifyShellLayer requires server-side shellctl settings and must use a provider factory.")
@classmethod
def from_config_with_settings(
cls,
config: DifyShellLayerConfig,
*,
shellctl_entrypoint: str | None,
shellctl_client_factory: ShellctlClientFactory,
) -> Self:
"""Create the layer from public config plus server-only shellctl settings."""
normalized_entrypoint = (shellctl_entrypoint or "").strip()
if not normalized_entrypoint:
raise ValueError(
"DifyShellLayer requires a non-empty DIFY_AGENT_SHELLCTL_ENTRYPOINT when the 'dify.shell' layer is used."
)
return cls(
config=config,
shellctl_entrypoint=normalized_entrypoint,
shellctl_client_factory=shellctl_client_factory,
)
@property
@override
def prefix_prompts(self) -> Sequence[PydanticAIPrompt[object]]:
return [_shell_layer_prefix_prompt]
@property
@override
def tools(self) -> Sequence[PydanticAITool[object]]:
return [
Tool(self._tool_run, name="shell.run"),
Tool(self._tool_wait, name="shell.wait"),
Tool(self._tool_input, name="shell.input"),
Tool(self._tool_interrupt, name="shell.interrupt"),
]
@override
@asynccontextmanager
async def resource_context(self) -> AsyncGenerator[None]:
"""Hold one live shellctl client for one active Agenton layer scope.
The shellctl client is a non-serializable live resource, so Agenton owns
only the timing of this scope, not the client itself. Business hooks and
tools should call ``_require_client()`` to ensure they are running inside
an active resource scope.
"""
if self._shellctl_client is not None:
raise RuntimeError("DifyShellLayer resource_context() is already active for this layer instance.")
client = self.shellctl_client_factory(self.shellctl_entrypoint)
self._shellctl_client = client
try:
yield
finally:
self._shellctl_client = None
await client.close()
@override
async def on_context_create(self) -> None:
"""Allocate a new workspace session using the active live shellctl client.
If workspace setup partially succeeds and this hook later raises, the
layer never becomes ``ACTIVE``. In that path Agenton still exits
``resource_context()``, but ``on_context_delete()`` will not run, so this
hook must clean up any tracked shellctl job artifacts before re-raising.
"""
try:
_ = self._require_client()
session_id, workspace_cwd = await self._allocate_workspace()
except BaseException:
await self._cleanup_create_failure()
raise
self.runtime_state = DifyShellRuntimeState.model_validate(
{
**self.runtime_state.model_dump(mode="python"),
"session_id": session_id,
"workspace_cwd": workspace_cwd,
}
)
@override
async def on_context_resume(self) -> None:
"""Resume an existing serialized shell session inside an active resource scope.
If a future resume path adds self-heal side effects before raising, this
hook must compensate for them itself because failed resume attempts never
transition the slot back to ``ACTIVE`` and therefore do not receive a
normal suspend/delete hook.
"""
_ = self._require_client()
_ = self._require_session_identity()
@override
async def on_context_suspend(self) -> None:
"""Preserve workspace and job state while the live client remains active.
``resource_context()`` owns client teardown after this hook returns.
"""
_ = self._require_client()
@override
async def on_context_delete(self) -> None:
"""Best-effort cleanup for workspace deletion and tracked shellctl jobs.
Workspace removal must happen before tracked shellctl job deletion because
the cleanup itself is implemented as an internal shellctl run. That means
deleting job state first would prevent the layer from issuing the
proposal-required ``rm -rf`` cleanup job and then cleaning up that final
job record along with the rest of the session's tracked shellctl state.
``resource_context()`` closes the live client only after this hook
finishes.
"""
_ = self._require_client()
cleanup_job_id: str | None = None
identity = self._try_session_identity()
if identity is not None:
session_id, _workspace_cwd = identity
try:
cleanup_result = await self._run_internal_job_to_completion(
_workspace_cleanup_script(session_id=session_id),
cwd=None,
)
cleanup_job_id = cleanup_result["job_id"]
if cleanup_result["exit_code"] != 0:
logger.warning(
"Shell workspace cleanup job %s for session %s exited with code %s.",
cleanup_job_id,
session_id,
cleanup_result["exit_code"],
)
except (RuntimeError, ValueError, ShellctlClientError) as exc:
logger.warning("Failed to remove shell workspace for session %s: %s", session_id, exc)
tracked_job_ids = _deduplicate_preserving_order(
[*self.runtime_state.job_ids, *([cleanup_job_id] if cleanup_job_id is not None else [])]
)
await self._delete_tracked_jobs_best_effort(tracked_job_ids)
self._clear_tracked_jobs()
async def _tool_run(self, script: str, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> ShellRunToolResult:
"""Start a new shell job inside the session workspace."""
try:
client = self._require_client()
result = await client.run(script, cwd=self._require_workspace_cwd(), timeout=timeout)
self._track_job_result(result)
return _job_result_observation(result)
except (RuntimeError, ValueError, ShellctlClientError) as exc:
return _tool_error(str(exc))
async def _tool_wait(self, job_id: str, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> ShellRunToolResult:
"""Wait for more output or completion from a tracked shell job."""
try:
client = self._require_client()
offset = self._tracked_offset(job_id)
result = await client.wait(job_id, offset=offset, timeout=timeout)
self._track_job_result(result)
return _job_result_observation(result)
except (RuntimeError, ValueError, ShellctlClientError) as exc:
return _tool_error(str(exc), job_id=job_id)
async def _tool_input(self, job_id: str, text: str, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> ShellRunToolResult:
"""Send text input to a tracked shell job and wait for output."""
try:
client = self._require_client()
offset = self._tracked_offset(job_id)
result = await client.input(job_id, text, offset=offset, timeout=timeout)
self._track_job_result(result)
return _job_result_observation(result)
except (RuntimeError, ValueError, ShellctlClientError) as exc:
return _tool_error(str(exc), job_id=job_id)
async def _tool_interrupt(
self,
job_id: str,
grace_seconds: float = DEFAULT_TERMINATE_GRACE_SECONDS,
) -> ShellInterruptToolResult:
"""Interrupt a tracked shell job without removing its persisted shellctl state."""
try:
client = self._require_client()
self._ensure_tracked_job(job_id)
result = await client.terminate(job_id, grace_seconds=grace_seconds)
self._track_job_status(result)
return _job_status_observation(result)
except (RuntimeError, ValueError, ShellctlClientError) as exc:
return _tool_error(str(exc), job_id=job_id)
async def _allocate_workspace(self) -> tuple[str, str]:
"""Allocate a unique ``~/workspace/<session_id>`` directory by mkdir collision checks."""
for _attempt in range(_SESSION_ID_ATTEMPT_LIMIT):
session_id = _generate_session_id()
mkdir_result = await self._run_internal_job_to_completion(
_workspace_mkdir_script(session_id=session_id),
cwd=None,
)
if mkdir_result["exit_code"] == _WORKSPACE_COLLISION_EXIT_CODE:
continue
if mkdir_result["exit_code"] != 0:
raise RuntimeError(
f"Failed to create shell workspace {_workspace_cwd(session_id)}: {mkdir_result['status']} exit_code={mkdir_result['exit_code']}"
)
return session_id, _workspace_cwd(session_id)
raise RuntimeError("Failed to allocate a unique shell workspace session id after 256 attempts.")
async def _cleanup_create_failure(self) -> None:
"""Best-effort shellctl job cleanup for create failures before ACTIVE state.
Agenton only calls ``on_context_delete`` for layers that successfully
entered ``ACTIVE``. If ``on_context_create`` fails after issuing one or
more internal shellctl jobs, those tracked job artifacts would otherwise
leak because no later lifecycle hook owns them. ``resource_context()``
still closes the live client for this failed enter attempt after the hook
unwinds.
"""
if not self.runtime_state.job_ids:
return
try:
await self._delete_tracked_jobs_best_effort(self.runtime_state.job_ids)
finally:
self._clear_tracked_jobs()
async def _run_internal_job_to_completion(
self,
script: str,
*,
cwd: str | None,
) -> ShellJobObservation:
"""Run an internal lifecycle command, track it, and wait for completion."""
client = self._require_client()
result = await client.run(script, cwd=cwd, timeout=DEFAULT_TIMEOUT_SECONDS)
self._track_job_result(result)
while not result.done:
result = await client.wait(
result.job_id,
offset=self._tracked_offset(result.job_id),
timeout=DEFAULT_TIMEOUT_SECONDS,
)
self._track_job_result(result)
return _job_result_observation(result)
def _require_client(self) -> ShellctlClientProtocol:
"""Return the live client or reject tool/lifecycle use without one."""
if self._shellctl_client is None:
raise RuntimeError(
"DifyShellLayer requires an active shellctl client inside resource_context(); "
+ "enter the layer through Agenton or wrap direct hook/tool usage in resource_context()."
)
return self._shellctl_client
def _require_workspace_cwd(self) -> str:
"""Return the configured workspace directory for user-facing shell jobs."""
_session_id, workspace_cwd = self._require_session_identity()
return workspace_cwd
def _require_session_identity(self) -> tuple[str, str]:
"""Return the stored session id and workspace path or raise for corrupt state."""
identity = self._try_session_identity()
if identity is None:
raise ValueError("DifyShellLayer runtime state is missing session_id or workspace_cwd.")
session_id, workspace_cwd = identity
expected_workspace = _workspace_cwd(session_id)
if workspace_cwd != expected_workspace:
raise ValueError(
f"DifyShellLayer runtime state has inconsistent workspace_cwd {workspace_cwd!r}; expected {expected_workspace!r}."
)
return session_id, workspace_cwd
def _try_session_identity(self) -> tuple[str, str] | None:
session_id = self.runtime_state.session_id
workspace_cwd = self.runtime_state.workspace_cwd
if session_id is None or workspace_cwd is None:
return None
return session_id, workspace_cwd
def _ensure_tracked_job(self, job_id: str) -> None:
"""Reject tool access to job ids not tracked in the current runtime state.
This first version treats shellctl job ids as opaque strings and uses
membership in ``runtime_state.job_ids`` as the tool-access boundary for
wait/input/interrupt operations.
"""
if job_id not in self.runtime_state.job_ids:
raise ValueError(f"Unknown shell job id for this session: {job_id}.")
def _tracked_offset(self, job_id: str) -> int:
"""Return the stored offset for a tracked job, defaulting legacy state to zero."""
self._ensure_tracked_job(job_id)
return int(self.runtime_state.job_offsets.get(job_id, 0))
def _track_job_result(self, result: JobResult) -> None:
"""Track one output-oriented shellctl result in serializable runtime state."""
self._remember_job_id(result.job_id)
self._remember_job_offset(result.job_id, result.offset)
def _track_job_status(self, result: JobStatusView) -> None:
"""Track status-only shellctl results that still carry the latest offset."""
self._remember_job_id(result.job_id)
self._remember_job_offset(result.job_id, result.offset)
def _remember_job_id(self, job_id: str) -> None:
if job_id in self.runtime_state.job_ids:
return
self.runtime_state.job_ids = [*self.runtime_state.job_ids, job_id]
def _remember_job_offset(self, job_id: str, offset: int) -> None:
job_offsets = dict(self.runtime_state.job_offsets)
job_offsets[job_id] = offset
self.runtime_state.job_offsets = job_offsets
async def _delete_tracked_jobs_best_effort(self, job_ids: Sequence[str]) -> None:
"""Force-delete tracked shellctl jobs, ignoring already-missing ones."""
client = self._require_client()
for job_id in _deduplicate_preserving_order(job_ids):
try:
_ = await client.delete(job_id, force=True)
except ShellctlClientError as exc:
if exc.code == "job_not_found":
continue
logger.warning(
"Failed to delete shellctl job %s for session %s: %s",
job_id,
self.runtime_state.session_id,
exc,
)
except RuntimeError as exc:
logger.warning(
"Failed to delete shellctl job %s for session %s: %s",
job_id,
self.runtime_state.session_id,
exc,
)
def _clear_tracked_jobs(self) -> None:
self.runtime_state.job_offsets = {}
self.runtime_state.job_ids = []
def _shell_layer_prefix_prompt() -> str:
"""Return the static model-facing shell tool usage guidance."""
return _SHELL_LAYER_PREFIX_PROMPT
def create_shellctl_client_factory(*, token: str) -> ShellctlClientFactory:
"""Return the default shellctl client factory used by server-side providers."""
def factory(entrypoint: str) -> ShellctlClientProtocol:
return ShellctlClient(entrypoint, token=token)
return factory
def _job_result_observation(result: JobResult) -> ShellJobObservation:
return {
"job_id": result.job_id,
"status": result.status.value,
"done": result.done,
"exit_code": result.exit_code,
"output": result.output,
"offset": result.offset,
"truncated": result.truncated,
"output_path": result.output_path,
}
def _job_status_observation(result: JobStatusView) -> ShellJobStatusObservation:
return {
"job_id": result.job_id,
"status": result.status.value,
"done": result.done,
"exit_code": result.exit_code,
"offset": result.offset,
}
def _tool_error(message: str, *, job_id: str | None = None) -> ShellToolErrorObservation:
result: ShellToolErrorObservation = {"error": message}
if job_id is not None:
result["job_id"] = job_id
return result
def _generate_session_id() -> str:
time_component = int(time.time()) & _SESSION_TIME_HEX_MASK
random_component = secrets.token_hex(1)
if len(random_component) != _SESSION_RANDOM_HEX_LENGTH:
raise RuntimeError("Expected a one-byte random hex suffix for Dify shell session ids.")
return f"{time_component:05x}{random_component}"
def _workspace_cwd(session_id: str) -> str:
return f"{_WORKSPACE_ROOT}/{_validated_session_id(session_id)}"
def _workspace_mkdir_script(*, session_id: str) -> str:
"""Return the internal mkdir command used for proposal-defined collision checks.
The parent ``$HOME/workspace`` directory is created with ``mkdir -p`` so it
can already exist, but the final session directory intentionally uses plain
``mkdir``. That second call is the collision detector: when the target
already exists, the script maps that case to ``_WORKSPACE_COLLISION_EXIT_CODE``
so ``on_context_create()`` can retry with a different random suffix instead
of silently reusing another session's workspace.
"""
safe_session_id = _validated_session_id(session_id)
workspace_dir = f'$HOME/workspace/{safe_session_id}'
return (
'mkdir -p "$HOME/workspace"; '
f'if mkdir "{workspace_dir}"; then exit 0; fi; '
f'if [ -e "{workspace_dir}" ]; then exit {_WORKSPACE_COLLISION_EXIT_CODE}; fi; '
'exit 1'
)
def _workspace_cleanup_script(*, session_id: str) -> str:
return f'rm -rf -- "$HOME/workspace/{_validated_session_id(session_id)}"'
def _validated_session_id(session_id: str) -> str:
if not _SESSION_ID_PATTERN.fullmatch(session_id):
raise ValueError("session_id must match the 5+2 lowercase hex format '<5 hex><2 hex>'.")
return session_id
def _deduplicate_preserving_order(values: Sequence[str]) -> list[str]:
seen: set[str] = set()
result: list[str] = []
for value in values:
if value in seen:
continue
seen.add(value)
result.append(value)
return result
__all__ = [
"DifyShellLayer",
"DifyShellRuntimeState",
"ShellctlClientFactory",
"ShellctlClientProtocol",
"create_shellctl_client_factory",
]

View File

@ -2,18 +2,22 @@
Only explicitly allowed provider type ids are constructible here. The default
provider set contains prompt layers, the optional pydantic-ai history layer, the
state-free Dify structured output layer, the Dify execution-context layer, and
the Dify plugin business-layer family:
state-free Dify structured output layer, the Dify execution-context layer, the
stateful Dify shell layer, and the Dify plugin business-layer family:
- ``dify.execution_context`` for shared tenant/user/run daemon context,
- ``dify.shell`` for shellctl-backed shell job control,
- ``dify.plugin.llm`` for plugin-backed model selection, and
- ``dify.plugin.tools`` for prepared plugin tool exposure.
Public DTOs provide Dify context plus plugin/model/tool data, while server-only
plugin daemon settings are injected through the provider factory for
``DifyExecutionContextLayer``. The resulting ``Compositor`` remains Agenton
state-only: live resources such as the plugin daemon HTTP client are supplied
later by the runtime and never enter providers, layers, or session snapshots.
``DifyExecutionContextLayer`` and the optional shellctl entrypoint/auth token plus
client factory are injected for ``DifyShellLayer``. The resulting ``Compositor``
remains Agenton state-only at the snapshot boundary: live resources such as
HTTP clients are injected by runtime-owned providers, may be held on active
layer instances inside ``resource_context()``, and never enter session
snapshots.
"""
from collections.abc import Mapping, Sequence
@ -31,6 +35,8 @@ from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer
from dify_agent.layers.execution_context.configs import DifyExecutionContextLayerConfig
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
from dify_agent.layers.output.output_layer import DifyOutputLayer
from dify_agent.layers.shell.configs import DifyShellLayerConfig
from dify_agent.layers.shell.layer import DifyShellLayer, create_shellctl_client_factory
type DifyAgentLayerProvider = LayerProvider[Any]
@ -40,8 +46,18 @@ def create_default_layer_providers(
*,
plugin_daemon_url: str = "http://localhost:5002",
plugin_daemon_api_key: str = "",
shellctl_entrypoint: str | None = None,
shellctl_auth_token: str | None = None,
) -> tuple[DifyAgentLayerProvider, ...]:
"""Return the server provider set of safe config-constructible layers."""
"""Return the server provider set of safe config-constructible layers.
``shellctl_auth_token`` defaults to no token. Passing an explicit empty string
to ``create_shellctl_client_factory`` prevents ``ShellctlClient`` from falling
back to the Dify Agent process's ``SHELLCTL_AUTH_TOKEN`` environment variable;
deployments that enable shellctl bearer auth must set the Dify Agent server
setting explicitly.
"""
shellctl_token = shellctl_auth_token or ""
return (
LayerProvider.from_layer_type(PromptLayer),
LayerProvider.from_layer_type(PydanticAIHistoryLayer),
@ -54,6 +70,14 @@ def create_default_layer_providers(
daemon_api_key=plugin_daemon_api_key,
),
),
LayerProvider.from_factory(
layer_type=DifyShellLayer,
create=lambda config: DifyShellLayer.from_config_with_settings(
DifyShellLayerConfig.model_validate(config),
shellctl_entrypoint=shellctl_entrypoint,
shellctl_client_factory=create_shellctl_client_factory(token=shellctl_token),
),
),
LayerProvider.from_layer_type(DifyPluginLLMLayer),
LayerProvider.from_layer_type(DifyPluginToolsLayer),
)

View File

@ -110,16 +110,20 @@ class AgentRunRunner:
async def _run_agent(self) -> tuple[JsonValue, CompositorSessionSnapshot]:
"""Run pydantic-ai inside an entered Agenton run.
Known input-shaped Agenton enter-time runtime errors, such as trying to
resume a ``CLOSED`` snapshot layer, are normalized to
``AgentRunValidationError``. Output/history-layer graph invariants are
validated from the public composition before entering Agenton so
misnamed or extra reserved layers never silently degrade. Later runtime
failures still propagate as execution errors so they become terminal
failed runs rather than client validation responses. Structured output
uses a resolved contract whose type itself encodes both the model-facing
schema and the runtime validation hooks, so invalid model outputs can be
corrected before Dify Agent emits success.
Known request-shaped Agenton enter-time failures are normalized to
``AgentRunValidationError``. That includes the existing small class of
enter-time ``RuntimeError`` values reported by Agenton plus
layer-construction or snapshot-hydration ``ValueError`` failures that
arise before the run becomes active, such as missing shell settings for a
requested ``dify.shell`` layer or malformed serialized shell offsets.
Output/history-layer graph invariants are validated from the public
composition before entering Agenton so misnamed or extra reserved layers
never silently degrade. Later runtime failures still propagate as
execution errors so they become terminal failed runs rather than client
validation responses. Structured output uses a resolved contract whose
type itself encodes both the model-facing schema and the runtime
validation hooks, so invalid model outputs can be corrected before Dify
Agent emits success.
"""
try:
validate_output_layer_composition(self.request.composition)
@ -172,6 +176,10 @@ class AgentRunRunner:
if not entered_run and is_agenton_enter_validation_runtime_error(exc):
raise AgentRunValidationError(str(exc)) from exc
raise
except ValueError as exc:
if not entered_run:
raise AgentRunValidationError(str(exc)) from exc
raise
if run.session_snapshot is None:
raise RuntimeError("Agenton run did not produce a session snapshot after exit.")

View File

@ -6,7 +6,8 @@ route wiring, and a process-local scheduler. Run execution happens in background
cancel the agent runtime. Redis persists run records and per-run event streams
with configured retention only; it is not used as a job queue. Agenton layers and
providers stay state-only: they borrow the lifespan-owned plugin daemon client
through the runner and never create or close it themselves.
through the runner and receive shell-layer server settings through provider
construction rather than reading environment variables themselves.
"""
from collections.abc import AsyncGenerator
@ -29,6 +30,8 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
layer_providers = create_default_layer_providers(
plugin_daemon_url=resolved_settings.plugin_daemon_url,
plugin_daemon_api_key=resolved_settings.plugin_daemon_api_key,
shellctl_entrypoint=resolved_settings.shellctl_entrypoint,
shellctl_auth_token=resolved_settings.shellctl_auth_token,
)
state: dict[str, object] = {}

View File

@ -3,7 +3,9 @@
Plugin daemon HTTP client settings describe the single FastAPI lifespan-owned
``httpx.AsyncClient`` shared by local run tasks. Layers and Agenton providers do
not own that client, so these settings are process resource limits rather than
per-run lifecycle knobs.
per-run lifecycle knobs. Optional shell-layer settings stay here as well because
the server injects them into layer providers instead of letting runtime modules
read process environment variables directly.
"""
from typing import ClassVar
@ -15,7 +17,7 @@ DEFAULT_RUN_RETENTION_SECONDS = 3 * 24 * 60 * 60
class ServerSettings(BaseSettings):
"""Environment-backed settings for Redis, scheduling, and plugin daemon access."""
"""Environment-backed settings for Redis, scheduling, plugin, and shell access."""
redis_url: str = "redis://localhost:6379/0"
redis_prefix: str = "dify-agent"
@ -23,6 +25,8 @@ class ServerSettings(BaseSettings):
run_retention_seconds: int = Field(default=DEFAULT_RUN_RETENTION_SECONDS, ge=1)
plugin_daemon_url: str = "http://localhost:5002"
plugin_daemon_api_key: str = ""
shellctl_entrypoint: str | None = None
shellctl_auth_token: str | None = None
plugin_daemon_connect_timeout: float = Field(default=10.0, ge=0)
plugin_daemon_read_timeout: float = Field(default=600.0, ge=0)
plugin_daemon_write_timeout: float = Field(default=30.0, ge=0)

View File

@ -113,6 +113,16 @@ def test_undefined_dependency_target_is_rejected_for_compositor_construction() -
Compositor([LayerNode("consumer", RenamedConsumerLayer, deps={"renamed": "missing_target"})])
def test_dependency_target_must_precede_dependent_layer_in_graph_order() -> None:
with pytest.raises(ValueError, match="must target preceding layer nodes"):
Compositor(
[
LayerNode("consumer", RenamedConsumerLayer, deps={"renamed": "target"}),
LayerNode("target", _object_provider("value")),
]
)
def test_duplicate_layer_node_name_is_rejected() -> None:
with pytest.raises(ValueError, match="Duplicate layer name 'same'"):
Compositor(

View File

@ -1,5 +1,6 @@
import asyncio
from collections.abc import Iterator
from collections.abc import AsyncGenerator, Iterator
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from itertools import count
@ -388,6 +389,586 @@ def test_closed_snapshot_enter_is_rejected_before_hooks_run() -> None:
assert created_layers[0].events == []
class ResourceState(BaseModel):
created_with_resource: bool = False
deleted_with_resource: bool = False
saw_dependency_resource: bool = False
model_config = ConfigDict(extra="forbid", validate_assignment=True)
@dataclass(slots=True)
class ParentResourceLayer(PlainLayer[NoLayerDeps, EmptyLayerConfig, ResourceState]):
events: list[str] = field(default_factory=list)
live_resource: object | None = None
@override
@asynccontextmanager
async def resource_context(self) -> AsyncGenerator[None]:
self.events.append("parent.resource.enter")
self.live_resource = object()
try:
yield
finally:
self.events.append("parent.resource.exit")
self.live_resource = None
@override
async def on_context_create(self) -> None:
assert self.live_resource is not None
self.events.append("parent.create")
self.runtime_state.created_with_resource = True
@override
async def on_context_delete(self) -> None:
assert self.live_resource is not None
self.events.append("parent.delete")
self.runtime_state.deleted_with_resource = True
@override
async def on_context_suspend(self) -> None:
assert self.live_resource is not None
self.events.append("parent.suspend")
class ChildResourceDeps(NoLayerDeps):
parent: ParentResourceLayer # pyright: ignore[reportUninitializedInstanceVariable]
@dataclass(slots=True)
class ChildResourceLayer(PlainLayer[ChildResourceDeps, EmptyLayerConfig, ResourceState]):
events: list[str] = field(default_factory=list)
live_resource: object | None = None
@override
@asynccontextmanager
async def resource_context(self) -> AsyncGenerator[None]:
self.events.append("child.resource.enter")
self.live_resource = object()
try:
yield
finally:
self.events.append("child.resource.exit")
self.live_resource = None
@override
async def on_context_create(self) -> None:
assert self.live_resource is not None
self.events.append("child.create")
self.runtime_state.created_with_resource = True
self.runtime_state.saw_dependency_resource = self.deps.parent.live_resource is not None
@override
async def on_context_delete(self) -> None:
assert self.live_resource is not None
self.events.append("child.delete")
self.runtime_state.deleted_with_resource = True
@dataclass(slots=True)
class CreateFailureResourceLayer(PlainLayer[NoLayerDeps]):
events: list[str] = field(default_factory=list)
live_resource: bool = False
@override
@asynccontextmanager
async def resource_context(self) -> AsyncGenerator[None]:
self.events.append("resource.enter")
self.live_resource = True
try:
yield
finally:
self.events.append("resource.exit")
self.live_resource = False
@override
async def on_context_create(self) -> None:
assert self.live_resource is True
self.events.append("create")
raise RuntimeError("create failed")
@override
async def on_context_delete(self) -> None:
self.events.append("delete")
@dataclass(slots=True)
class DeleteFailureResourceLayer(PlainLayer[NoLayerDeps]):
events: list[str] = field(default_factory=list)
live_resource: bool = False
@override
@asynccontextmanager
async def resource_context(self) -> AsyncGenerator[None]:
self.events.append("resource.enter")
self.live_resource = True
try:
yield
finally:
self.events.append("resource.exit")
self.live_resource = False
@override
async def on_context_create(self) -> None:
assert self.live_resource is True
self.events.append("create")
@override
async def on_context_delete(self) -> None:
assert self.live_resource is True
self.events.append("delete")
raise RuntimeError("delete failed")
class ResumeResourceState(BaseModel):
created_with_resource: bool = False
resumed_with_resource: bool = False
suspended_with_resource: bool = False
deleted_with_resource: bool = False
model_config = ConfigDict(extra="forbid", validate_assignment=True)
@dataclass(slots=True)
class SuspendResumeResourceLayer(PlainLayer[NoLayerDeps, EmptyLayerConfig, ResumeResourceState]):
events: list[str]
next_resource_id: Iterator[int]
live_resource: str | None = None
@override
@asynccontextmanager
async def resource_context(self) -> AsyncGenerator[None]:
self.live_resource = f"resource-{next(self.next_resource_id)}"
self.events.append(f"resource.enter:{self.live_resource}")
try:
yield
finally:
assert self.live_resource is not None
self.events.append(f"resource.exit:{self.live_resource}")
self.live_resource = None
@override
async def on_context_create(self) -> None:
assert self.live_resource is not None
self.events.append(f"create:{self.live_resource}")
self.runtime_state.created_with_resource = True
@override
async def on_context_resume(self) -> None:
assert self.live_resource is not None
self.events.append(f"resume:{self.live_resource}")
self.runtime_state.resumed_with_resource = True
@override
async def on_context_suspend(self) -> None:
assert self.live_resource is not None
self.events.append(f"suspend:{self.live_resource}")
self.runtime_state.suspended_with_resource = True
@override
async def on_context_delete(self) -> None:
assert self.live_resource is not None
self.events.append(f"delete:{self.live_resource}")
self.runtime_state.deleted_with_resource = True
class CreateFailureChildResourceLayer(ChildResourceLayer):
@override
async def on_context_create(self) -> None:
assert self.live_resource is not None
self.events.append("child.create")
raise RuntimeError("child create failed")
class SuspendFailureChildResourceLayer(ChildResourceLayer):
@override
async def on_context_suspend(self) -> None:
assert self.live_resource is not None
self.events.append("child.suspend")
raise RuntimeError("child suspend failed")
def test_resource_context_wraps_hooks_and_body_in_dependency_order() -> None:
events: list[str] = []
compositor = Compositor(
[
LayerNode(
"parent",
LayerProvider.from_factory(
layer_type=ParentResourceLayer,
create=lambda config: ParentResourceLayer(events),
),
),
LayerNode(
"child",
LayerProvider.from_factory(
layer_type=ChildResourceLayer,
create=lambda config: ChildResourceLayer(events),
),
deps={"parent": "parent"},
),
]
)
async def run() -> CompositorSessionSnapshot:
async with compositor.enter() as active_run:
parent = active_run.get_layer("parent", ParentResourceLayer)
child = active_run.get_layer("child", ChildResourceLayer)
assert parent.live_resource is not None
assert child.live_resource is not None
assert child.deps.parent is parent
events.append("body")
assert active_run.session_snapshot is not None
assert parent.live_resource is None
assert child.live_resource is None
return active_run.session_snapshot
snapshot = asyncio.run(run())
assert events == [
"parent.resource.enter",
"parent.create",
"child.resource.enter",
"child.create",
"body",
"child.delete",
"child.resource.exit",
"parent.delete",
"parent.resource.exit",
]
assert snapshot.model_dump(mode="json") == {
"schema_version": 1,
"layers": [
{
"name": "parent",
"lifecycle_state": "closed",
"runtime_state": {
"created_with_resource": True,
"deleted_with_resource": True,
"saw_dependency_resource": False,
},
},
{
"name": "child",
"lifecycle_state": "closed",
"runtime_state": {
"created_with_resource": True,
"deleted_with_resource": True,
"saw_dependency_resource": True,
},
},
],
}
def test_resource_context_wraps_resume_and_suspend_with_fresh_resource_scope() -> None:
events: list[str] = []
resource_ids = count(1)
created_layers: list[SuspendResumeResourceLayer] = []
def create_layer(config: EmptyLayerConfig) -> SuspendResumeResourceLayer:
layer = SuspendResumeResourceLayer(events=events, next_resource_id=resource_ids)
created_layers.append(layer)
return layer
compositor = Compositor(
[LayerNode("trace", LayerProvider.from_factory(layer_type=SuspendResumeResourceLayer, create=create_layer))]
)
async def run() -> tuple[CompositorSessionSnapshot, CompositorSessionSnapshot]:
async with compositor.enter() as first_run:
first_layer = first_run.get_layer("trace", SuspendResumeResourceLayer)
assert first_layer.live_resource == "resource-1"
first_run.suspend_on_exit()
assert first_run.session_snapshot is not None
async with compositor.enter(session_snapshot=first_run.session_snapshot) as resumed_run:
resumed_layer = resumed_run.get_layer("trace", SuspendResumeResourceLayer)
assert resumed_layer.live_resource == "resource-2"
assert resumed_layer.live_resource != "resource-1"
assert resumed_run.session_snapshot is not None
return first_run.session_snapshot, resumed_run.session_snapshot
suspended_snapshot, resumed_snapshot = asyncio.run(run())
assert len(created_layers) == 2
assert all(layer.live_resource is None for layer in created_layers)
assert events == [
"resource.enter:resource-1",
"create:resource-1",
"suspend:resource-1",
"resource.exit:resource-1",
"resource.enter:resource-2",
"resume:resource-2",
"delete:resource-2",
"resource.exit:resource-2",
]
assert suspended_snapshot.model_dump(mode="json") == {
"schema_version": 1,
"layers": [
{
"name": "trace",
"lifecycle_state": "suspended",
"runtime_state": {
"created_with_resource": True,
"resumed_with_resource": False,
"suspended_with_resource": True,
"deleted_with_resource": False,
},
}
],
}
assert resumed_snapshot.model_dump(mode="json") == {
"schema_version": 1,
"layers": [
{
"name": "trace",
"lifecycle_state": "closed",
"runtime_state": {
"created_with_resource": True,
"resumed_with_resource": True,
"suspended_with_resource": True,
"deleted_with_resource": True,
},
}
],
}
def test_resource_context_exits_when_run_body_raises() -> None:
events: list[str] = []
created_layers: list[ParentResourceLayer | ChildResourceLayer] = []
def create_parent(config: EmptyLayerConfig) -> ParentResourceLayer:
layer = ParentResourceLayer(events)
created_layers.append(layer)
return layer
def create_child(config: EmptyLayerConfig) -> ChildResourceLayer:
layer = ChildResourceLayer(events)
created_layers.append(layer)
return layer
compositor = Compositor(
[
LayerNode("parent", LayerProvider.from_factory(layer_type=ParentResourceLayer, create=create_parent)),
LayerNode(
"child",
LayerProvider.from_factory(layer_type=ChildResourceLayer, create=create_child),
deps={"parent": "parent"},
),
]
)
async def run() -> None:
async with compositor.enter():
events.append("body")
raise RuntimeError("body failed")
with pytest.raises(RuntimeError, match="body failed"):
asyncio.run(run())
assert [layer.live_resource for layer in created_layers] == [None, None]
assert events == [
"parent.resource.enter",
"parent.create",
"child.resource.enter",
"child.create",
"body",
"child.delete",
"child.resource.exit",
"parent.delete",
"parent.resource.exit",
]
def test_resource_context_exits_when_run_body_is_cancelled() -> None:
events: list[str] = []
created_layers: list[ParentResourceLayer | ChildResourceLayer] = []
def create_parent(config: EmptyLayerConfig) -> ParentResourceLayer:
layer = ParentResourceLayer(events)
created_layers.append(layer)
return layer
def create_child(config: EmptyLayerConfig) -> ChildResourceLayer:
layer = ChildResourceLayer(events)
created_layers.append(layer)
return layer
compositor = Compositor(
[
LayerNode("parent", LayerProvider.from_factory(layer_type=ParentResourceLayer, create=create_parent)),
LayerNode(
"child",
LayerProvider.from_factory(layer_type=ChildResourceLayer, create=create_child),
deps={"parent": "parent"},
),
]
)
async def run() -> None:
async with compositor.enter():
events.append("body")
task = asyncio.current_task()
assert task is not None
task.cancel()
await asyncio.sleep(0)
with pytest.raises(asyncio.CancelledError):
asyncio.run(run())
assert [layer.live_resource for layer in created_layers] == [None, None]
assert events == [
"parent.resource.enter",
"parent.create",
"child.resource.enter",
"child.create",
"body",
"child.delete",
"child.resource.exit",
"parent.delete",
"parent.resource.exit",
]
def test_dependency_resource_contexts_exit_when_child_create_fails() -> None:
events: list[str] = []
created_layers: list[ParentResourceLayer | CreateFailureChildResourceLayer] = []
def create_parent(config: EmptyLayerConfig) -> ParentResourceLayer:
layer = ParentResourceLayer(events)
created_layers.append(layer)
return layer
def create_child(config: EmptyLayerConfig) -> CreateFailureChildResourceLayer:
layer = CreateFailureChildResourceLayer(events)
created_layers.append(layer)
return layer
compositor = Compositor(
[
LayerNode("parent", LayerProvider.from_factory(layer_type=ParentResourceLayer, create=create_parent)),
LayerNode(
"child",
LayerProvider.from_factory(layer_type=CreateFailureChildResourceLayer, create=create_child),
deps={"parent": "parent"},
),
]
)
with pytest.raises(RuntimeError, match="child create failed"):
asyncio.run(_enter_once(compositor))
assert [layer.live_resource for layer in created_layers] == [None, None]
assert events == [
"parent.resource.enter",
"parent.create",
"child.resource.enter",
"child.create",
"child.resource.exit",
"parent.delete",
"parent.resource.exit",
]
def test_dependency_resource_contexts_exit_when_child_suspend_fails() -> None:
events: list[str] = []
created_layers: list[ParentResourceLayer | SuspendFailureChildResourceLayer] = []
def create_parent(config: EmptyLayerConfig) -> ParentResourceLayer:
layer = ParentResourceLayer(events)
created_layers.append(layer)
return layer
def create_child(config: EmptyLayerConfig) -> SuspendFailureChildResourceLayer:
layer = SuspendFailureChildResourceLayer(events)
created_layers.append(layer)
return layer
compositor = Compositor(
[
LayerNode("parent", LayerProvider.from_factory(layer_type=ParentResourceLayer, create=create_parent)),
LayerNode(
"child",
LayerProvider.from_factory(layer_type=SuspendFailureChildResourceLayer, create=create_child),
deps={"parent": "parent"},
),
]
)
async def run() -> None:
async with compositor.enter() as active_run:
events.append("body")
active_run.suspend_on_exit()
with pytest.raises(RuntimeError, match="child suspend failed"):
asyncio.run(run())
assert [layer.live_resource for layer in created_layers] == [None, None]
assert events == [
"parent.resource.enter",
"parent.create",
"child.resource.enter",
"child.create",
"body",
"child.suspend",
"child.resource.exit",
"parent.suspend",
"parent.resource.exit",
]
def test_resource_context_exits_when_create_hook_raises() -> None:
created_layers: list[CreateFailureResourceLayer] = []
def create_layer(config: EmptyLayerConfig) -> CreateFailureResourceLayer:
layer = CreateFailureResourceLayer()
created_layers.append(layer)
return layer
compositor = Compositor(
[
LayerNode(
"trace",
LayerProvider.from_factory(layer_type=CreateFailureResourceLayer, create=create_layer),
)
]
)
with pytest.raises(RuntimeError, match="create failed"):
asyncio.run(_enter_once(compositor))
assert len(created_layers) == 1
assert created_layers[0].events == ["resource.enter", "create", "resource.exit"]
assert created_layers[0].live_resource is False
def test_resource_context_exits_when_delete_hook_raises() -> None:
deleted_layers: list[DeleteFailureResourceLayer] = []
def create_layer(config: EmptyLayerConfig) -> DeleteFailureResourceLayer:
layer = DeleteFailureResourceLayer()
deleted_layers.append(layer)
return layer
compositor = Compositor(
[
LayerNode(
"trace",
LayerProvider.from_factory(layer_type=DeleteFailureResourceLayer, create=create_layer),
)
]
)
with pytest.raises(RuntimeError, match="delete failed"):
asyncio.run(_enter_once(compositor))
assert len(deleted_layers) == 1
assert deleted_layers[0].events == ["resource.enter", "create", "delete", "resource.exit"]
assert deleted_layers[0].live_resource is False
async def _enter_once(
compositor: Compositor,
*,

View File

@ -0,0 +1,20 @@
import pytest
from pydantic import ValidationError
import dify_agent.layers.shell as shell_exports
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
def test_shell_package_exports_client_safe_config_symbols_only() -> None:
assert shell_exports.__all__ == ["DIFY_SHELL_LAYER_TYPE_ID", "DifyShellLayerConfig"]
assert DIFY_SHELL_LAYER_TYPE_ID == "dify.shell"
assert not hasattr(shell_exports, "DifyShellLayer")
def test_shell_layer_config_is_empty_and_forbids_unknown_fields() -> None:
config = DifyShellLayerConfig()
assert config.model_dump() == {}
with pytest.raises(ValidationError):
_ = DifyShellLayerConfig.model_validate({"entrypoint": "http://shellctl"})

View File

@ -0,0 +1,588 @@
import asyncio
from collections.abc import Callable
import secrets
import time
from dataclasses import dataclass
import pytest
from agenton.compositor import Compositor, LayerNode, LayerProvider
from agenton.layers import LifecycleState
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
from dify_agent.layers.shell.layer import DifyShellLayer, DifyShellRuntimeState, ShellctlClientFactory
from shell_session_manager.shellctl.shared import DeleteJobResponse, JobResult, JobStatusName, JobStatusView
def _job_result(
job_id: str,
*,
status: JobStatusName = JobStatusName.RUNNING,
done: bool = False,
exit_code: int | None = None,
output: str = "",
offset: int = 0,
truncated: bool = False,
output_path: str = "/tmp/output.log",
) -> JobResult:
return JobResult(
job_id=job_id,
status=status,
done=done,
exit_code=exit_code,
output=output,
offset=offset,
truncated=truncated,
output_path=output_path,
)
def _job_status(
job_id: str,
*,
status: JobStatusName = JobStatusName.RUNNING,
done: bool = False,
exit_code: int | None = None,
offset: int = 0,
) -> JobStatusView:
return JobStatusView(
job_id=job_id,
status=status,
done=done,
exit_code=exit_code,
created_at="2026-05-28T12:00:00Z",
started_at="2026-05-28T12:00:01Z",
ended_at="2026-05-28T12:00:02Z" if done else None,
offset=offset,
)
def _assert_error_observation(result: object, *, job_id: str | None = None, includes: str | None = None) -> None:
assert isinstance(result, dict)
assert isinstance(result.get("error"), str)
assert result["error"]
if job_id is None:
assert "job_id" not in result
else:
assert result.get("job_id") == job_id
if includes is not None:
assert includes in result["error"]
@dataclass(slots=True)
class RunCall:
script: str
cwd: str | None
timeout: float
@dataclass(slots=True)
class WaitCall:
job_id: str
offset: int
timeout: float
@dataclass(slots=True)
class InputCall:
job_id: str
text: str
offset: int
timeout: float
@dataclass(slots=True)
class TerminateCall:
job_id: str
grace_seconds: float
@dataclass(slots=True)
class DeleteCall:
job_id: str
force: bool
grace_seconds: float | None
class FakeShellctlClient:
run_calls: list[RunCall]
wait_calls: list[WaitCall]
input_calls: list[InputCall]
terminate_calls: list[TerminateCall]
delete_calls: list[DeleteCall]
events: list[tuple[str, str]]
closed: bool
def __init__(
self,
*,
run_handler: Callable[[str, str | None, float], JobResult] | None = None,
wait_handler: Callable[[str, int, float], JobResult] | None = None,
input_handler: Callable[[str, str, int, float], JobResult] | None = None,
terminate_handler: Callable[[str, float], JobStatusView] | None = None,
delete_handler: Callable[[str, bool, float | None], DeleteJobResponse] | None = None,
) -> None:
self._run_handler = run_handler
self._wait_handler = wait_handler
self._input_handler = input_handler
self._terminate_handler = terminate_handler
self._delete_handler = delete_handler
self.run_calls = []
self.wait_calls = []
self.input_calls = []
self.terminate_calls = []
self.delete_calls = []
self.events = []
self.closed = False
async def run(self, script: str, *, cwd: str | None = None, timeout: float = 10.0) -> JobResult:
self.run_calls.append(RunCall(script=script, cwd=cwd, timeout=timeout))
self.events.append(("run", script))
if self._run_handler is None:
raise AssertionError("Unexpected run() call")
return self._run_handler(script, cwd, timeout)
async def wait(self, job_id: str, *, offset: int, timeout: float = 10.0) -> JobResult:
self.wait_calls.append(WaitCall(job_id=job_id, offset=offset, timeout=timeout))
self.events.append(("wait", job_id))
if self._wait_handler is None:
raise AssertionError("Unexpected wait() call")
return self._wait_handler(job_id, offset, timeout)
async def input(self, job_id: str, text: str, *, offset: int, timeout: float = 10.0) -> JobResult:
self.input_calls.append(InputCall(job_id=job_id, text=text, offset=offset, timeout=timeout))
self.events.append(("input", job_id))
if self._input_handler is None:
raise AssertionError("Unexpected input() call")
return self._input_handler(job_id, text, offset, timeout)
async def terminate(self, job_id: str, grace_seconds: float = 2.0) -> JobStatusView:
self.terminate_calls.append(TerminateCall(job_id=job_id, grace_seconds=grace_seconds))
self.events.append(("terminate", job_id))
if self._terminate_handler is None:
raise AssertionError("Unexpected terminate() call")
return self._terminate_handler(job_id, grace_seconds)
async def delete(
self,
job_id: str,
*,
force: bool = False,
grace_seconds: float | None = None,
) -> DeleteJobResponse:
self.delete_calls.append(DeleteCall(job_id=job_id, force=force, grace_seconds=grace_seconds))
self.events.append(("delete", job_id))
if self._delete_handler is None:
return DeleteJobResponse(job_id=job_id)
return self._delete_handler(job_id, force, grace_seconds)
async def close(self) -> None:
self.closed = True
self.events.append(("close", "client"))
def _shell_layer(*, client_factory: ShellctlClientFactory) -> DifyShellLayer:
return DifyShellLayer.from_config_with_settings(
DifyShellLayerConfig(),
shellctl_entrypoint="http://shellctl",
shellctl_client_factory=client_factory,
)
def _shell_provider(*, client_factory: ShellctlClientFactory) -> LayerProvider[DifyShellLayer]:
return LayerProvider.from_factory(
layer_type=DifyShellLayer,
create=lambda config: DifyShellLayer.from_config_with_settings(
DifyShellLayerConfig.model_validate(config),
shellctl_entrypoint="http://shellctl",
shellctl_client_factory=client_factory,
),
)
def test_shell_type_id_constant_matches_implementation_class() -> None:
assert DIFY_SHELL_LAYER_TYPE_ID == DifyShellLayer.type_id
def test_shell_layer_create_generates_5_plus_2_hex_session_id_and_retries_workspace_collision(
monkeypatch: pytest.MonkeyPatch,
) -> None:
random_suffixes = iter(["aa", "bb"])
monkeypatch.setattr(time, "time", lambda: 0x12345F)
monkeypatch.setattr(secrets, "token_hex", lambda nbytes: next(random_suffixes))
def run_handler(script: str, cwd: str | None, timeout: float) -> JobResult:
assert cwd is None
assert timeout == 30.0
if "2345faa" in script:
return _job_result("mkdir-collision", status=JobStatusName.EXITED, done=True, exit_code=17)
if "2345fbb" in script:
return _job_result("mkdir-success", status=JobStatusName.RUNNING, done=False, offset=4)
raise AssertionError(f"Unexpected script: {script}")
def wait_handler(job_id: str, offset: int, timeout: float) -> JobResult:
assert job_id == "mkdir-success"
assert offset == 4
assert timeout == 30.0
return _job_result("mkdir-success", status=JobStatusName.EXITED, done=True, exit_code=0, offset=8)
client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler)
layer = _shell_layer(client_factory=lambda _entrypoint: client)
async def scenario() -> None:
async with layer.resource_context():
await layer.on_context_create()
assert client.closed is False
asyncio.run(scenario())
assert layer.runtime_state.session_id == "2345fbb"
assert layer.runtime_state.workspace_cwd == "~/workspace/2345fbb"
assert layer.runtime_state.job_ids == ["mkdir-collision", "mkdir-success"]
assert layer.runtime_state.job_offsets == {"mkdir-collision": 0, "mkdir-success": 8}
assert 'mkdir "$HOME/workspace/2345fbb"' in client.run_calls[1].script
assert 'mkdir -p "$HOME/workspace/2345fbb"' not in client.run_calls[1].script
assert client.closed is True
def test_shell_layer_suspend_leaves_client_open_until_resource_context_exits() -> None:
client = FakeShellctlClient()
layer = _shell_layer(client_factory=lambda _entrypoint: client)
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
async def scenario() -> None:
async with layer.resource_context():
await layer.on_context_suspend()
assert client.closed is False
asyncio.run(scenario())
assert client.closed is True
def test_shell_layer_suspend_and_resume_reuse_state_with_fresh_clients() -> None:
first_client = FakeShellctlClient(
run_handler=lambda _script, _cwd, _timeout: _job_result(
"mkdir-job",
status=JobStatusName.EXITED,
done=True,
exit_code=0,
)
)
second_client = FakeShellctlClient()
created_entrypoints: list[str] = []
clients = iter([first_client, second_client])
def factory(entrypoint: str) -> FakeShellctlClient:
created_entrypoints.append(entrypoint)
return next(clients)
compositor = Compositor([LayerNode("shell", _shell_provider(client_factory=factory))])
async def scenario() -> None:
async with compositor.enter(configs={"shell": DifyShellLayerConfig()}) as run:
shell_layer = run.get_layer("shell", DifyShellLayer)
initial_session_id = shell_layer.runtime_state.session_id
assert initial_session_id is not None
assert shell_layer.runtime_state.workspace_cwd == f"~/workspace/{initial_session_id}"
shell_layer.runtime_state.job_ids = [*shell_layer.runtime_state.job_ids, "user-job"]
shell_layer.runtime_state.job_offsets = {
**shell_layer.runtime_state.job_offsets,
"user-job": 42,
}
assert first_client.closed is False
run.suspend_layer_on_exit("shell")
assert run.session_snapshot is not None
assert first_client.closed is True
assert run.session_snapshot.layers[0].lifecycle_state is LifecycleState.SUSPENDED
async with compositor.enter(
configs={"shell": DifyShellLayerConfig()},
session_snapshot=run.session_snapshot,
) as resumed_run:
resumed_shell = resumed_run.get_layer("shell", DifyShellLayer)
assert second_client.closed is False
assert resumed_shell.runtime_state.session_id == initial_session_id
assert resumed_shell.runtime_state.workspace_cwd == f"~/workspace/{initial_session_id}"
assert set(resumed_shell.runtime_state.job_ids) == {"mkdir-job", "user-job"}
assert resumed_shell.runtime_state.job_offsets == {"mkdir-job": 0, "user-job": 42}
resumed_run.suspend_layer_on_exit("shell")
assert second_client.closed is True
asyncio.run(scenario())
assert created_entrypoints == ["http://shellctl", "http://shellctl"]
def test_shell_layer_delete_removes_workspace_then_force_deletes_tracked_jobs_and_closes_client() -> None:
def run_handler(script: str, cwd: str | None, timeout: float) -> JobResult:
assert script == 'rm -rf -- "$HOME/workspace/abc12ff"'
assert cwd is None
assert timeout == 30.0
return _job_result("cleanup-job", status=JobStatusName.RUNNING, done=False, offset=3)
def wait_handler(job_id: str, offset: int, timeout: float) -> JobResult:
assert job_id == "cleanup-job"
assert offset == 3
assert timeout == 30.0
return _job_result("cleanup-job", status=JobStatusName.EXITED, done=True, exit_code=0, offset=5)
client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler)
layer = _shell_layer(client_factory=lambda _entrypoint: client)
async def scenario() -> None:
async with layer.resource_context():
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
layer.runtime_state.job_ids = ["user-job", "mkdir-job"]
layer.runtime_state.job_offsets = {"user-job": 9, "mkdir-job": 1}
await layer.on_context_delete()
assert client.closed is False
asyncio.run(scenario())
assert client.events[:2] == [("run", 'rm -rf -- "$HOME/workspace/abc12ff"'), ("wait", "cleanup-job")]
assert {call.job_id for call in client.delete_calls} == {"user-job", "mkdir-job", "cleanup-job"}
assert all(client.events.index(("delete", call.job_id)) > client.events.index(("wait", "cleanup-job")) for call in client.delete_calls)
assert all(call.force is True for call in client.delete_calls)
assert layer.runtime_state.job_ids == []
assert layer.runtime_state.job_offsets == {}
assert client.closed is True
def test_shell_layer_create_failure_force_deletes_internal_jobs_before_reraising() -> None:
client = FakeShellctlClient(
run_handler=lambda _script, _cwd, _timeout: _job_result(
"mkdir-failed",
status=JobStatusName.EXITED,
done=True,
exit_code=1,
)
)
layer = _shell_layer(client_factory=lambda _entrypoint: client)
async def scenario() -> None:
with pytest.raises(RuntimeError, match="Failed to create shell workspace"):
async with layer.resource_context():
await layer.on_context_create()
asyncio.run(scenario())
assert [call.job_id for call in client.delete_calls] == ["mkdir-failed"]
assert all(call.force is True for call in client.delete_calls)
assert layer.runtime_state.job_ids == []
assert layer.runtime_state.job_offsets == {}
assert client.closed is True
def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() -> None:
def run_handler(script: str, cwd: str | None, timeout: float) -> JobResult:
assert script == "pwd"
assert cwd == "~/workspace/abc12ff"
assert timeout == 2.5
return _job_result(
"user-job",
status=JobStatusName.RUNNING,
done=False,
offset=10,
output="/home/test\n",
)
def wait_handler(job_id: str, offset: int, timeout: float) -> JobResult:
assert job_id == "user-job"
assert offset == 10
assert timeout == 4.0
return _job_result(
"user-job",
status=JobStatusName.RUNNING,
done=False,
offset=18,
output="more\n",
)
def input_handler(job_id: str, text: str, offset: int, timeout: float) -> JobResult:
assert job_id == "user-job"
assert text == "ls\n"
assert offset == 18
assert timeout == 5.0
return _job_result(
"user-job",
status=JobStatusName.EXITED,
done=True,
exit_code=0,
offset=22,
output="file.txt\n",
)
def terminate_handler(job_id: str, grace_seconds: float) -> JobStatusView:
assert job_id == "user-job"
assert grace_seconds == 1.5
return _job_status(
"user-job",
status=JobStatusName.TERMINATED,
done=True,
exit_code=130,
offset=22,
)
client = FakeShellctlClient(
run_handler=run_handler,
wait_handler=wait_handler,
input_handler=input_handler,
terminate_handler=terminate_handler,
)
layer = _shell_layer(client_factory=lambda _entrypoint: client)
tools = {tool.name: tool for tool in layer.tools}
async def scenario() -> None:
async with layer.resource_context():
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
run_tool_def = await tools["shell.run"].prepare_tool_def(None) # pyright: ignore[reportArgumentType]
wait_tool_def = await tools["shell.wait"].prepare_tool_def(None) # pyright: ignore[reportArgumentType]
input_tool_def = await tools["shell.input"].prepare_tool_def(None) # pyright: ignore[reportArgumentType]
interrupt_tool_def = await tools["shell.interrupt"].prepare_tool_def(None) # pyright: ignore[reportArgumentType]
run_result = await tools["shell.run"].function_schema.call(
{"script": "pwd", "timeout": 2.5},
None, # pyright: ignore[reportArgumentType]
)
wait_result = await tools["shell.wait"].function_schema.call(
{"job_id": "user-job", "timeout": 4.0},
None, # pyright: ignore[reportArgumentType]
)
input_result = await tools["shell.input"].function_schema.call(
{"job_id": "user-job", "text": "ls\n", "timeout": 5.0},
None, # pyright: ignore[reportArgumentType]
)
interrupt_result = await tools["shell.interrupt"].function_schema.call(
{"job_id": "user-job", "grace_seconds": 1.5},
None, # pyright: ignore[reportArgumentType]
)
assert run_tool_def is not None
assert wait_tool_def is not None
assert input_tool_def is not None
assert interrupt_tool_def is not None
assert "offset" not in run_tool_def.parameters_json_schema.get("properties", {})
assert "offset" not in wait_tool_def.parameters_json_schema.get("properties", {})
assert "offset" not in input_tool_def.parameters_json_schema.get("properties", {})
assert "offset" not in interrupt_tool_def.parameters_json_schema.get("properties", {})
assert set(tools) == {"shell.run", "shell.wait", "shell.input", "shell.interrupt"}
assert run_result["job_id"] == "user-job"
assert run_result["offset"] == 10
assert wait_result["offset"] == 18
assert input_result["offset"] == 22
assert interrupt_result == {
"job_id": "user-job",
"status": "terminated",
"done": True,
"exit_code": 130,
"offset": 22,
}
assert client.closed is False
asyncio.run(scenario())
assert layer.runtime_state.job_ids == ["user-job"]
assert layer.runtime_state.job_offsets == {"user-job": 22}
assert client.closed is True
def test_shell_layer_tools_reject_untracked_job_ids_without_shellctl_calls() -> None:
client = FakeShellctlClient()
layer = _shell_layer(client_factory=lambda _entrypoint: client)
tools = {tool.name: tool for tool in layer.tools}
async def scenario() -> None:
async with layer.resource_context():
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
wait_result = await tools["shell.wait"].function_schema.call(
{"job_id": "missing-job"},
None, # pyright: ignore[reportArgumentType]
)
input_result = await tools["shell.input"].function_schema.call(
{"job_id": "missing-job", "text": "hello"},
None, # pyright: ignore[reportArgumentType]
)
interrupt_result = await tools["shell.interrupt"].function_schema.call(
{"job_id": "missing-job"},
None, # pyright: ignore[reportArgumentType]
)
_assert_error_observation(wait_result, job_id="missing-job")
_assert_error_observation(input_result, job_id="missing-job")
_assert_error_observation(interrupt_result, job_id="missing-job")
asyncio.run(scenario())
assert client.wait_calls == []
assert client.input_calls == []
assert client.terminate_calls == []
def test_shell_layer_hooks_and_tools_fail_clearly_outside_active_resource_context() -> None:
client = FakeShellctlClient()
layer = _shell_layer(client_factory=lambda _entrypoint: client)
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
tools = {tool.name: tool for tool in layer.tools}
async def scenario() -> None:
with pytest.raises(RuntimeError, match="resource_context"):
await layer.on_context_suspend()
run_result = await tools["shell.run"].function_schema.call(
{"script": "pwd"},
None, # pyright: ignore[reportArgumentType]
)
_assert_error_observation(run_result, includes="resource_context")
asyncio.run(scenario())
assert client.run_calls == []
def test_shell_runtime_state_rejects_unsafe_resumed_workspace_identity() -> None:
with pytest.raises(ValueError, match="session_id must match"):
_ = DifyShellRuntimeState.model_validate(
{
"session_id": "../../tmp",
"workspace_cwd": "~/workspace/../../tmp",
"job_ids": [],
"job_offsets": {},
}
)
with pytest.raises(ValueError, match="workspace_cwd must equal"):
_ = DifyShellRuntimeState.model_validate(
{
"session_id": "abc12ff",
"workspace_cwd": "~/workspace/def34aa",
"job_ids": [],
"job_offsets": {},
}
)
def test_shell_runtime_state_treats_job_ids_as_opaque_strings_and_rejects_unknown_offset_keys() -> None:
state = DifyShellRuntimeState.model_validate(
{
"session_id": "abc12ff",
"workspace_cwd": "~/workspace/abc12ff",
"job_ids": ['job"bad with spaces'],
"job_offsets": {'job"bad with spaces': 0},
}
)
assert state.job_ids == ['job"bad with spaces']
assert state.job_offsets == {'job"bad with spaces': 0}
with pytest.raises(ValueError, match="unknown job ids"):
_ = DifyShellRuntimeState.model_validate(
{
"session_id": "abc12ff",
"workspace_cwd": "~/workspace/abc12ff",
"job_ids": ["job-1"],
"job_offsets": {"job-2": 3},
}
)

View File

@ -0,0 +1,73 @@
import pytest
import dify_agent.runtime.compositor_factory as compositor_factory_module
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
from dify_agent.layers.shell.layer import DifyShellLayer
from dify_agent.runtime.compositor_factory import create_default_layer_providers
class FakeFactoryClient:
async def close(self) -> None:
return None
def test_default_layer_providers_register_shell_layer_with_configured_token_factory(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured_tokens: list[str] = []
captured_entrypoints: list[str] = []
fake_client = FakeFactoryClient()
def fake_create_shellctl_client_factory(*, token: str):
captured_tokens.append(token)
def factory(entrypoint: str) -> FakeFactoryClient:
captured_entrypoints.append(entrypoint)
return fake_client
return factory
monkeypatch.setattr(compositor_factory_module, "create_shellctl_client_factory", fake_create_shellctl_client_factory)
providers = create_default_layer_providers(
shellctl_entrypoint="http://shellctl.example",
shellctl_auth_token="shell-secret",
)
shell_provider = next(provider for provider in providers if provider.type_id == DIFY_SHELL_LAYER_TYPE_ID)
shell_layer = shell_provider.create_layer(DifyShellLayerConfig())
assert isinstance(shell_layer, DifyShellLayer)
assert shell_layer.shellctl_entrypoint == "http://shellctl.example"
assert captured_tokens == ["shell-secret"]
assert shell_layer.shellctl_client_factory(shell_layer.shellctl_entrypoint) is fake_client
assert captured_entrypoints == ["http://shellctl.example"]
def test_default_layer_providers_keep_empty_shellctl_token_by_default(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured_tokens: list[str] = []
def fake_create_shellctl_client_factory(*, token: str):
captured_tokens.append(token)
def factory(_entrypoint: str) -> FakeFactoryClient:
return FakeFactoryClient()
return factory
monkeypatch.setattr(compositor_factory_module, "create_shellctl_client_factory", fake_create_shellctl_client_factory)
providers = create_default_layer_providers(shellctl_entrypoint="http://shellctl.example")
shell_provider = next(provider for provider in providers if provider.type_id == DIFY_SHELL_LAYER_TYPE_ID)
_ = shell_provider.create_layer(DifyShellLayerConfig())
assert captured_tokens == [""]
def test_shell_provider_rejects_blank_settings_entrypoint_only_when_shell_layer_is_created() -> None:
providers = create_default_layer_providers(shellctl_entrypoint=" ")
shell_provider = next(provider for provider in providers if provider.type_id == DIFY_SHELL_LAYER_TYPE_ID)
with pytest.raises(ValueError, match="DIFY_AGENT_SHELLCTL_ENTRYPOINT"):
_ = shell_provider.create_layer(DifyShellLayerConfig())

View File

@ -25,6 +25,8 @@ from agenton.layers import ExitIntent, LifecycleState
from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID, PydanticAIHistoryRuntimeState
from agenton_collections.layers.plain import PromptLayerConfig, ToolsLayer
from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
from dify_agent.layers.shell.layer import DifyShellLayer
from dify_agent.layers.dify_plugin.configs import (
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
DifyPluginLLMLayerConfig,
@ -48,12 +50,60 @@ from dify_agent.protocol.schemas import (
from dify_agent.runtime.event_sink import InMemoryRunEventSink
from dify_agent.runtime.compositor_factory import create_default_layer_providers
from dify_agent.runtime.runner import AgentRunRunner, AgentRunValidationError
from shell_session_manager.shellctl.shared import DeleteJobResponse, JobResult, JobStatusName, JobStatusView
class StaticToolsTestLayer(ToolsLayer):
type_id: ClassVar[str] = "test.static.tools"
class FakeRunnerShellctlClient:
run_calls: list[tuple[str, str | None, float]]
closed: bool
def __init__(self) -> None:
self.run_calls = []
self.closed = False
async def run(self, script: str, *, cwd: str | None = None, timeout: float = 10.0) -> JobResult:
self.run_calls.append((script, cwd, timeout))
return JobResult(
job_id="mkdir-job",
status=JobStatusName.EXITED,
done=True,
exit_code=0,
output_path="/tmp/output.log",
output="",
offset=0,
truncated=False,
)
async def close(self) -> None:
self.closed = True
async def wait(self, job_id: str, *, offset: int, timeout: float = 10.0) -> JobResult:
del job_id, offset, timeout
raise AssertionError("wait() should not be called in this test")
async def input(self, job_id: str, text: str, *, offset: int, timeout: float = 10.0) -> JobResult:
del job_id, text, offset, timeout
raise AssertionError("input() should not be called in this test")
async def terminate(self, job_id: str, grace_seconds: float = 2.0) -> JobStatusView:
del job_id, grace_seconds
raise AssertionError("terminate() should not be called in this test")
async def delete(
self,
job_id: str,
*,
force: bool = False,
grace_seconds: float | None = None,
) -> DeleteJobResponse:
del job_id, force, grace_seconds
raise AssertionError("delete() should not be called in this test")
def _request(
user: str | list[str] = "hello",
*,
@ -597,6 +647,116 @@ def test_runner_rejects_duplicate_tool_names_between_static_and_dynamic_tools(
assert sink.statuses["run-static-dynamic-duplicate-tools"] == "failed"
def test_runner_rejects_duplicate_tool_names_between_shell_and_other_layers(
monkeypatch: pytest.MonkeyPatch,
) -> None:
create_agent_called = False
shell_client = FakeRunnerShellctlClient()
def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient):
assert http_client.is_closed is False
return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType]
async def fake_get_tools(_self: DifyPluginToolsLayer, *, http_client: httpx.AsyncClient) -> list[Tool[object]]:
assert http_client.is_closed is False
async def duplicate_shell_run() -> str:
return "tool"
return [Tool(duplicate_shell_run, name="shell.run")]
def fake_create_agent(model: object, *, tools: list[Tool[object]], output_type: object) -> object:
del model, tools, output_type
nonlocal create_agent_called
create_agent_called = True
raise AssertionError("create_agent should not be called when duplicate tool names are detected")
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
monkeypatch.setattr(DifyPluginToolsLayer, "get_tools", fake_get_tools)
monkeypatch.setattr("dify_agent.runtime.runner.create_agent", fake_create_agent)
shell_provider = LayerProvider.from_factory(
layer_type=DifyShellLayer,
create=lambda config: DifyShellLayer.from_config_with_settings(
DifyShellLayerConfig.model_validate(config),
shellctl_entrypoint="http://shellctl",
shellctl_client_factory=lambda _entrypoint: shell_client,
),
)
layer_providers = tuple(
provider for provider in create_default_layer_providers(shellctl_entrypoint="http://unused")
if provider.type_id != DIFY_SHELL_LAYER_TYPE_ID
) + (shell_provider,)
request = CreateRunRequest(
composition=RunComposition(
layers=[
RunLayerSpec(
name="prompt",
type="plain.prompt",
config=PromptLayerConfig(prefix="system", user="hello"),
),
RunLayerSpec(name="shell", type=DIFY_SHELL_LAYER_TYPE_ID, config=DifyShellLayerConfig()),
RunLayerSpec(
name="execution_context",
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"),
),
RunLayerSpec(
name=DIFY_AGENT_MODEL_LAYER_ID,
type="dify.plugin.llm",
deps={"execution_context": "execution_context"},
config=DifyPluginLLMLayerConfig(
plugin_id="langgenius/openai",
model_provider="openai",
model="demo-model",
credentials={"api_key": "secret"},
),
),
RunLayerSpec(
name="tools",
type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
deps={"execution_context": "execution_context"},
config=DifyPluginToolsLayerConfig(
tools=[
DifyPluginToolConfig(
plugin_id="langgenius/tools",
provider="search",
tool_name="web_search",
credential_type="api-key",
parameters=_prepared_plugin_tool_parameters(),
parameters_json_schema=_prepared_plugin_tool_schema(),
)
]
),
),
]
)
)
sink = InMemoryRunEventSink()
async def scenario() -> None:
async with httpx.AsyncClient() as client:
with pytest.raises(
AgentRunValidationError,
match="unique tool names across all layers, got duplicates: shell.run",
):
await AgentRunRunner(
sink=sink,
request=request,
run_id="run-shell-duplicate-tools",
plugin_daemon_http_client=client,
layer_providers=layer_providers,
).run()
asyncio.run(scenario())
assert create_agent_called is False
assert shell_client.closed is True
assert [event.type for event in sink.events["run-shell-duplicate-tools"]] == ["run_started", "run_failed"]
assert sink.statuses["run-shell-duplicate-tools"] == "failed"
def test_runner_passes_temporary_system_prompt_prefix_without_history_layer(monkeypatch: pytest.MonkeyPatch) -> None:
model = RecordingTestModel(custom_output_text="done")
@ -1433,3 +1593,123 @@ def test_runner_rejects_closed_session_snapshot_as_validation_error() -> None:
assert [event.type for event in sink.events["run-closed-snapshot"]] == ["run_started", "run_failed"]
assert sink.statuses["run-closed-snapshot"] == "failed"
def test_runner_treats_missing_shell_entrypoint_as_validation_error() -> None:
request = CreateRunRequest(
composition=RunComposition(
layers=[
RunLayerSpec(
name="prompt",
type="plain.prompt",
config=PromptLayerConfig(prefix="system", user="hello"),
),
RunLayerSpec(name="shell", type=DIFY_SHELL_LAYER_TYPE_ID, config=DifyShellLayerConfig()),
RunLayerSpec(
name="execution_context",
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"),
),
RunLayerSpec(
name=DIFY_AGENT_MODEL_LAYER_ID,
type="dify.plugin.llm",
deps={"execution_context": "execution_context"},
config=DifyPluginLLMLayerConfig(
plugin_id="langgenius/openai",
model_provider="openai",
model="demo-model",
credentials={"api_key": "secret"},
),
),
]
)
)
sink = InMemoryRunEventSink()
async def scenario() -> None:
async with httpx.AsyncClient() as client:
with pytest.raises(AgentRunValidationError, match="DIFY_AGENT_SHELLCTL_ENTRYPOINT"):
await AgentRunRunner(
sink=sink,
request=request,
run_id="run-missing-shell-entrypoint",
plugin_daemon_http_client=client,
).run()
asyncio.run(scenario())
assert [event.type for event in sink.events["run-missing-shell-entrypoint"]] == ["run_started", "run_failed"]
assert sink.statuses["run-missing-shell-entrypoint"] == "failed"
def test_runner_treats_invalid_shell_snapshot_offsets_as_validation_error() -> None:
request = CreateRunRequest(
composition=RunComposition(
layers=[
RunLayerSpec(
name="prompt",
type="plain.prompt",
config=PromptLayerConfig(prefix="system", user="hello"),
),
RunLayerSpec(name="shell", type=DIFY_SHELL_LAYER_TYPE_ID, config=DifyShellLayerConfig()),
RunLayerSpec(
name="execution_context",
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"),
),
RunLayerSpec(
name=DIFY_AGENT_MODEL_LAYER_ID,
type="dify.plugin.llm",
deps={"execution_context": "execution_context"},
config=DifyPluginLLMLayerConfig(
plugin_id="langgenius/openai",
model_provider="openai",
model="demo-model",
credentials={"api_key": "secret"},
),
),
]
),
session_snapshot=CompositorSessionSnapshot(
layers=[
LayerSessionSnapshot(name="prompt", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}),
LayerSessionSnapshot(
name="shell",
lifecycle_state=LifecycleState.SUSPENDED,
runtime_state={
"session_id": "abc12ff",
"workspace_cwd": "~/workspace/abc12ff",
"job_ids": ["job-1"],
"job_offsets": {"job-1": -1},
},
),
LayerSessionSnapshot(
name="execution_context",
lifecycle_state=LifecycleState.SUSPENDED,
runtime_state={},
),
LayerSessionSnapshot(
name=DIFY_AGENT_MODEL_LAYER_ID,
lifecycle_state=LifecycleState.SUSPENDED,
runtime_state={},
),
]
),
)
sink = InMemoryRunEventSink()
async def scenario() -> None:
async with httpx.AsyncClient() as client:
with pytest.raises(AgentRunValidationError, match="job_offsets"):
await AgentRunRunner(
sink=sink,
request=request,
run_id="run-invalid-shell-offset",
plugin_daemon_http_client=client,
layer_providers=create_default_layer_providers(shellctl_entrypoint="http://shellctl"),
).run()
asyncio.run(scenario())
assert [event.type for event in sink.events["run-invalid-shell-offset"]] == ["run_started", "run_failed"]
assert sink.statuses["run-invalid-shell-offset"] == "failed"

View File

@ -1,13 +1,17 @@
from __future__ import annotations
import asyncio
from typing import ClassVar
import pytest
from fastapi.testclient import TestClient
from shell_session_manager.shellctl.client import ShellctlClient
import dify_agent.server.app as app_module
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
from dify_agent.layers.shell import DifyShellLayerConfig
from dify_agent.layers.shell.layer import DifyShellLayer
from dify_agent.runtime.compositor_factory import DifyAgentLayerProvider
from dify_agent.server.app import create_app, create_plugin_daemon_http_client
from dify_agent.server.settings import ServerSettings
@ -133,6 +137,8 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt
run_retention_seconds=7,
plugin_daemon_url="http://plugin-daemon",
plugin_daemon_api_key="daemon-secret",
shellctl_entrypoint="http://shellctl",
shellctl_auth_token="shell-secret",
plugin_daemon_connect_timeout=1,
plugin_daemon_read_timeout=2,
plugin_daemon_write_timeout=3,
@ -154,9 +160,17 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt
execution_context_layer = execution_context_provider.create_layer(
DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run")
)
shell_provider = next(provider for provider in layer_providers if provider.type_id == "dify.shell")
shell_layer = shell_provider.create_layer(DifyShellLayerConfig())
assert isinstance(execution_context_layer, DifyExecutionContextLayer)
assert isinstance(shell_layer, DifyShellLayer)
assert execution_context_layer.daemon_url == "http://plugin-daemon"
assert execution_context_layer.daemon_api_key == "daemon-secret"
assert shell_layer.shellctl_entrypoint == "http://shellctl"
shellctl_client = shell_layer.shellctl_client_factory("http://shellctl")
assert isinstance(shellctl_client, ShellctlClient)
assert shellctl_client.token == "shell-secret"
asyncio.run(shellctl_client.close())
http_client = scheduler.plugin_daemon_http_client
assert http_client is fake_http_client
assert http_client.is_closed is False

View File

@ -0,0 +1,33 @@
from pathlib import Path
import pytest
from dify_agent.server.settings import ServerSettings
def test_server_settings_reads_shellctl_entrypoint_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DIFY_AGENT_SHELLCTL_ENTRYPOINT", "http://shellctl.example")
settings = ServerSettings()
assert settings.shellctl_entrypoint == "http://shellctl.example"
def test_server_settings_reads_shellctl_auth_token_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("DIFY_AGENT_SHELLCTL_AUTH_TOKEN", "shell-secret")
settings = ServerSettings()
assert settings.shellctl_auth_token == "shell-secret"
def test_server_settings_defaults_shellctl_auth_token_to_none(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
monkeypatch.delenv("DIFY_AGENT_SHELLCTL_AUTH_TOKEN", raising=False)
monkeypatch.chdir(tmp_path)
settings = ServerSettings()
assert settings.shellctl_auth_token is None

View File

@ -0,0 +1,97 @@
from __future__ import annotations
from pathlib import Path
import shutil
import subprocess
import textwrap
import pytest
def test_client_public_exports_work_with_default_dependencies_only(tmp_path: Path) -> None:
"""Install the package without extras and verify client-facing imports work."""
uv = shutil.which("uv")
if uv is None:
pytest.skip("uv is required to verify default-dependency imports in an isolated environment")
project_root = Path(__file__).resolve().parents[3]
venv_path = tmp_path / "client-default-venv"
python_path = venv_path / "bin" / "python"
subprocess.run([uv, "venv", str(venv_path)], cwd=project_root, check=True)
subprocess.run(
[uv, "pip", "install", "--python", str(python_path), "."],
cwd=project_root,
check=True,
)
script = textwrap.dedent(
"""
from __future__ import annotations
import importlib
from importlib.metadata import PackageNotFoundError, distribution
from pathlib import Path
import re
import sys
import tomllib
def requirement_name(requirement: str) -> str:
match = re.match(r"\\s*([A-Za-z0-9_.-]+)", requirement)
if match is None:
raise AssertionError(f"Cannot parse requirement name: {requirement!r}")
return match.group(1).lower().replace("_", "-")
project_root = Path(sys.argv[1])
pyproject = tomllib.loads((project_root / "pyproject.toml").read_text())
default_dependency_names = {
requirement_name(requirement)
for requirement in pyproject["project"].get("dependencies", [])
}
server_dependency_names = {
requirement_name(requirement)
for requirement in pyproject["project"].get("optional-dependencies", {}).get("server", [])
}
server_only_dependency_names = server_dependency_names - default_dependency_names
agenton_layers = importlib.import_module("agenton.layers")
agenton_compositor = importlib.import_module("agenton.compositor")
agenton_collections = importlib.import_module("agenton_collections")
plain_layers = importlib.import_module("agenton_collections.layers.plain")
pydantic_ai_layers = importlib.import_module("agenton_collections.layers.pydantic_ai")
dify_agent = importlib.import_module("dify_agent")
client_module = importlib.import_module("dify_agent.client")
protocol_module = importlib.import_module("dify_agent.protocol")
shell_module = importlib.import_module("dify_agent.layers.shell")
execution_context_module = importlib.import_module("dify_agent.layers.execution_context")
plugin_module = importlib.import_module("dify_agent.layers.dify_plugin")
output_module = importlib.import_module("dify_agent.layers.output")
assert agenton_layers.ExitIntent is not None
assert agenton_layers.LayerConfig is not None
assert agenton_compositor.CompositorSessionSnapshot is not None
assert agenton_collections.PromptLayer is plain_layers.PromptLayer
assert plain_layers.PromptLayerConfig is not None
assert pydantic_ai_layers.PydanticAIHistoryLayer is not None
assert dify_agent.Client is client_module.Client
assert protocol_module.CreateRunRequest is not None
assert protocol_module.RunComposition is not None
assert protocol_module.RunLayerSpec is not None
assert shell_module.DifyShellLayerConfig is not None
assert execution_context_module.DifyExecutionContextLayerConfig is not None
assert plugin_module.DifyPluginLLMLayerConfig is not None
assert output_module.DifyOutputLayerConfig is not None
unexpectedly_installed = []
for dependency_name in sorted(server_only_dependency_names):
try:
distribution(dependency_name)
except PackageNotFoundError:
continue
unexpectedly_installed.append(dependency_name)
assert unexpectedly_installed == []
"""
)
subprocess.run([str(python_path), "-c", script, str(project_root)], cwd=project_root, check=True)

View File

@ -83,6 +83,7 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() ->
"dify_agent.layers.dify_plugin.llm_layer",
"dify_agent.layers.dify_plugin.tools_layer",
"dify_agent.layers.output.output_layer",
"dify_agent.layers.shell.layer",
"dify_agent.runtime",
"dify_agent.server",
"fastapi",
@ -91,18 +92,22 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() ->
"openai",
"pydantic_settings",
"redis",
"shell_session_manager.shellctl.client",
"shell_session_manager.shellctl.server",
],
imports=[
"dify_agent.protocol",
"dify_agent.layers.execution_context",
"dify_agent.layers.dify_plugin",
"dify_agent.layers.output",
"dify_agent.layers.shell",
],
assertions=[
"assert hasattr(dify_agent_protocol, 'PydanticAIStreamRunEvent')",
"assert dify_agent_layers_execution_context.__all__ == ['DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID', 'DifyExecutionContextInvokeFrom', 'DifyExecutionContextLayerConfig']",
"assert dify_agent_layers_dify_plugin.__all__ == ['DIFY_PLUGIN_LLM_LAYER_TYPE_ID', 'DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID', 'DifyPluginCredentialValue', 'DifyPluginLLMLayerConfig', 'DifyPluginToolCredentialType', 'DifyPluginToolConfig', 'DifyPluginToolOption', 'DifyPluginToolParameter', 'DifyPluginToolParameterForm', 'DifyPluginToolParameterType', 'DifyPluginToolsLayerConfig', 'DifyPluginToolValue']",
"assert dify_agent_layers_output.__all__ == ['DIFY_OUTPUT_LAYER_TYPE_ID', 'DifyOutputLayerConfig']",
"assert dify_agent_layers_shell.__all__ == ['DIFY_SHELL_LAYER_TYPE_ID', 'DifyShellLayerConfig']",
],
)

140
dify-agent/uv.lock generated
View File

@ -19,6 +19,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" },
]
[[package]]
name = "aiosqlite"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
]
[[package]]
name = "annotated-doc"
version = "0.0.4"
@ -589,6 +598,7 @@ server = [
{ name = "pydantic-ai-slim", extra = ["anthropic", "google", "openai"] },
{ name = "pydantic-settings" },
{ name = "redis" },
{ name = "shell-session-manager" },
{ name = "uvicorn", extra = ["standard"] },
]
@ -619,6 +629,7 @@ requires-dist = [
{ name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1,<2.0.0" },
{ name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0,<3.0.0" },
{ name = "redis", marker = "extra == 'server'", specifier = ">=7.4.0,<8.0.0" },
{ name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.1.1" },
{ name = "typing-extensions", specifier = ">=4.12.2,<5.0.0" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = "==0.46.0" },
]
@ -811,6 +822,61 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/de/89/a6340afdaf5169d17a318e00fc685fb67ed99baa602c2cbbbf6af6a76096/graphon-0.2.2-py3-none-any.whl", hash = "sha256:754e544d08779138f99eac6547ab08559463680e2c76488b05e1c978210392b4", size = 340808, upload-time = "2026-04-17T08:52:26.5Z" },
]
[[package]]
name = "greenlet"
version = "3.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" },
{ url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" },
{ url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" },
{ url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" },
{ url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" },
{ url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" },
{ url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" },
{ url = "https://files.pythonhosted.org/packages/62/90/ceca11f504cd23a8047a3dea31919adc48df9b626dd0c13f0d858734fdfd/greenlet-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", size = 235580, upload-time = "2026-05-20T13:08:45.056Z" },
{ url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" },
{ url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" },
{ url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" },
{ url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" },
{ url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" },
{ url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" },
{ url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" },
{ url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" },
{ url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" },
{ url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" },
{ url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" },
{ url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" },
{ url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" },
{ url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" },
{ url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" },
{ url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" },
{ url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" },
{ url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" },
{ url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" },
{ url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" },
{ url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" },
{ url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" },
{ url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" },
{ url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" },
{ url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" },
{ url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" },
{ url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" },
{ url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" },
{ url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" },
{ url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" },
{ url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" },
{ url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" },
]
[[package]]
name = "griffelib"
version = "2.0.2"
@ -2946,6 +3012,25 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" },
]
[[package]]
name = "shell-session-manager"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiosqlite" },
{ name = "anyio" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "sqlmodel" },
{ name = "typer" },
{ name = "uvicorn" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a4/64/8d12611e48553d61423d5e302d178e67bd968a35f1709e26024f4e04fc3b/shell_session_manager-2.1.1.tar.gz", hash = "sha256:bf490809161244beb95cabad62d32a59b351b7b5993e375d49b6fcf3835ae31c", size = 47064, upload-time = "2026-05-29T20:04:27.625Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/74/64d6db5888f6e7c7dcf0b4960e9ffa8c38425fa906cd60e99ed0bd88def7/shell_session_manager-2.1.1-py3-none-any.whl", hash = "sha256:6b53c813ac386bbf3244c375edf9cce675c89a2041d33a969ef69d8d74f89ac6", size = 45742, upload-time = "2026-05-29T20:04:26.551Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
@ -3055,6 +3140,61 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/33/78/d1a1a026ef3af911159398c939b1509d5c36fe524c7b644f34a5146c4e16/spacy_loggers-1.0.5-py3-none-any.whl", hash = "sha256:196284c9c446cc0cdb944005384270d775fdeaf4f494d8e269466cfa497ef645", size = 22343, upload-time = "2023-09-11T12:26:50.586Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.50"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/b0/a9d19b43f38f878b1278bca5b00b909f7540d41494396dd2561f9ad0956d/sqlalchemy-2.0.50-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb", size = 2159807, upload-time = "2026-05-24T19:27:53.086Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/191dd58a248fd2cfd4780fa82c375c505e4ad98c8b522fa69ec492130d77/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47b71b933e7b4ebad407c8fdfd70d2c4f08b78b3238bb30eebdd6eb32ca51b89", size = 3343358, upload-time = "2026-05-24T20:09:29.279Z" },
{ url = "https://files.pythonhosted.org/packages/8a/2b/514fce8a7df81cf5bad7ff7865de7ac0c5776a38cc043475c4703eb7fe8b/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600", size = 3357994, upload-time = "2026-05-24T20:17:13.495Z" },
{ url = "https://files.pythonhosted.org/packages/35/a6/a0e283f5494f92b0d77e319ff77e437b1ffe4a051ba67c81d53234825475/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5e4ac70e9e757f6b3e87c0491ff034442ecd8dfd36d041a50564c322dafc0e", size = 3289399, upload-time = "2026-05-24T20:09:32.239Z" },
{ url = "https://files.pythonhosted.org/packages/b7/96/1b07325ba71752d6a028b77d07bed1483ad545f794e8b1dc89b3ba3b3c68/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:724f3dcbe53dd0151e3cb5e7ec4ba4c620bede579caacd16275dc35ce06e8615", size = 3321216, upload-time = "2026-05-24T20:17:15.581Z" },
{ url = "https://files.pythonhosted.org/packages/ed/8e/bad6ed253e8a99edfc99af02f7173ec48a1d3ed1b9b35a1b8bc1700900cc/sqlalchemy-2.0.50-cp312-cp312-win32.whl", hash = "sha256:1208050441471d003b7c8cb4054fb084f185cf35ac3f0ea270803865bca9939a", size = 2119194, upload-time = "2026-05-24T19:50:04.943Z" },
{ url = "https://files.pythonhosted.org/packages/b6/2d/314a6690dda4b9cfc571eab1a63cf6fe6e1470aa3759ccda6aa016ee0f5a/sqlalchemy-2.0.50-cp312-cp312-win_amd64.whl", hash = "sha256:9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7", size = 2146186, upload-time = "2026-05-24T19:50:06.74Z" },
{ url = "https://files.pythonhosted.org/packages/0b/c4/c42356b527296e9862f67990efce31ef78b4cf69cd3f80873a528a060320/sqlalchemy-2.0.50-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093", size = 2156697, upload-time = "2026-05-24T19:27:54.764Z" },
{ url = "https://files.pythonhosted.org/packages/60/a1/b1a70e3c4365ac7fe9e347f3710f19b562c866fb96d45e3c891588789a7b/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873", size = 3284260, upload-time = "2026-05-24T20:09:34.195Z" },
{ url = "https://files.pythonhosted.org/packages/3f/4a/f3ac3caa19f263d57b0a47f8c91bbf56583dc2d3fc63acfbf644abb24fe0/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db", size = 3302280, upload-time = "2026-05-24T20:17:17.825Z" },
{ url = "https://files.pythonhosted.org/packages/66/55/ccada3e3d62254587819749a0bc69f41173eb48a6e385d10e66d32a9c88e/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064", size = 3231580, upload-time = "2026-05-24T20:09:36.406Z" },
{ url = "https://files.pythonhosted.org/packages/05/f6/6809349130a2de0e109e7f00fd7d431da9565b9b2868b32ee684754f672b/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f", size = 3269375, upload-time = "2026-05-24T20:17:20.34Z" },
{ url = "https://files.pythonhosted.org/packages/48/84/278a811ef4e07be9c89dc5cdd7be833268509a66a68c4897cf585e67428f/sqlalchemy-2.0.50-cp313-cp313-win32.whl", hash = "sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5", size = 2117229, upload-time = "2026-05-24T19:50:08.215Z" },
{ url = "https://files.pythonhosted.org/packages/f6/1c/067cc6187ed32d2ec222fe6d2643acc1659a6d0659f8a7cbc5ad3ae83280/sqlalchemy-2.0.50-cp313-cp313-win_amd64.whl", hash = "sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3", size = 2143126, upload-time = "2026-05-24T19:50:09.691Z" },
{ url = "https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", size = 2158519, upload-time = "2026-05-24T19:27:56.472Z" },
{ url = "https://files.pythonhosted.org/packages/5a/76/e703d2f7681d7d66c4c891af3f07c7ccf4c76ad7f18351de035b5eda007a/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb", size = 3282063, upload-time = "2026-05-24T20:09:38.57Z" },
{ url = "https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", size = 3287069, upload-time = "2026-05-24T20:17:21.942Z" },
{ url = "https://files.pythonhosted.org/packages/c2/15/765acc2bc693bccc43ca4a95d5b69750da8aaf6db1b5c616536e087f8920/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d", size = 3230453, upload-time = "2026-05-24T20:09:40.398Z" },
{ url = "https://files.pythonhosted.org/packages/63/61/08e03c3adbf5db0087a0b6816746fec8f3032fb2f7fc899a9bb9b2a48ce4/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f", size = 3252413, upload-time = "2026-05-24T20:17:24.067Z" },
{ url = "https://files.pythonhosted.org/packages/03/0c/370a1f2db38436c615e10134c8a37de3688e74084792380695f3f5083860/sqlalchemy-2.0.50-cp314-cp314-win32.whl", hash = "sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8", size = 2120063, upload-time = "2026-05-24T19:50:11.08Z" },
{ url = "https://files.pythonhosted.org/packages/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.whl", hash = "sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39", size = 2145830, upload-time = "2026-05-24T19:50:12.452Z" },
{ url = "https://files.pythonhosted.org/packages/cc/ff/e5640a98a0b2f491eb8fde10fb6c773621a2e44340de231fafcc9370f4a9/sqlalchemy-2.0.50-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70", size = 2178435, upload-time = "2026-05-24T19:42:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/b7/85/337116e186f1236375b5fb70c21cfac98e8e8ab0d3a47be838dc47a59e08/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086", size = 3566059, upload-time = "2026-05-24T20:01:20.848Z" },
{ url = "https://files.pythonhosted.org/packages/96/34/bb0e190e161c3c2c24314a65add57218be14a4a9486886b7f5047c1ff7c8/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52", size = 3535366, upload-time = "2026-05-24T20:03:56.768Z" },
{ url = "https://files.pythonhosted.org/packages/df/5a/a7f759f97e4fd499c5d4e4488c760d5a7fbecf3028b465a04274fcd52384/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a", size = 3474879, upload-time = "2026-05-24T20:01:23.058Z" },
{ url = "https://files.pythonhosted.org/packages/9d/d9/2907ea38eb60687d297bf9c39e5ee58053c87b57fe8a9cae97090cecbf10/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d", size = 3486117, upload-time = "2026-05-24T20:03:59.052Z" },
{ url = "https://files.pythonhosted.org/packages/f2/e3/5aa06f167559f8c0bdae487e297d23ba548150ab016a3418265d617a4985/sqlalchemy-2.0.50-cp314-cp314t-win32.whl", hash = "sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e", size = 2150823, upload-time = "2026-05-24T20:08:58.644Z" },
{ url = "https://files.pythonhosted.org/packages/65/9b/112fb8f977582d7489d036e409e3723948bcf5320b3ac465f3c481bbe8f9/sqlalchemy-2.0.50-cp314-cp314t-win_amd64.whl", hash = "sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51", size = 2185794, upload-time = "2026-05-24T20:09:00.319Z" },
{ url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" },
]
[[package]]
name = "sqlmodel"
version = "0.0.38"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/64/0d/26ec1329960ea9430131fe63f63a95ea4cb8971d49c891ff7e1f3255421c/sqlmodel-0.0.38.tar.gz", hash = "sha256:d583ec237b14103809f74e8630032bc40ab68cd6b754a610f0813c56911a547b", size = 86710, upload-time = "2026-04-02T21:03:55.571Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/72/c7/10c60af0607ab6fa136264f7f39d205932218516226d38585324ffda705d/sqlmodel-0.0.38-py3-none-any.whl", hash = "sha256:84e3fa990a77395461ded72a6c73173438ce8449d5c1c4d97fbff1b1df692649", size = 27294, upload-time = "2026-04-02T21:03:56.406Z" },
]
[[package]]
name = "srsly"
version = "2.5.3"

View File

@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
import type { Virtualizer } from '@tanstack/react-virtual'
import type { RefObject } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useEffect, useMemo, useRef, useState, useTransition } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
Autocomplete,
AutocompleteClear,
@ -159,29 +159,6 @@ const virtualizedSuggestions: Suggestion[] = Array.from({ length: 1000 }, (_, in
const getSuggestionLabel = (item: Suggestion) => item.label
async function searchSuggestions(
suggestions: Suggestion[],
query: string,
filter: (item: string, query: string) => boolean,
): Promise<{ items: Suggestion[], error: string | null }> {
await new Promise(resolve => window.setTimeout(resolve, 500))
if (query === 'will_error') {
return {
items: [],
error: 'Failed to load suggestions. Please try again.',
}
}
return {
items: suggestions.filter(item => (
filter(item.label, query)
|| (item.description ? filter(item.description, query) : false)
)),
error: null,
}
}
const SuggestionItem = ({
item,
dense,
@ -250,7 +227,6 @@ const BasicTagAutocomplete = ({
<Autocomplete
items={tagSuggestions}
itemToStringValue={getSuggestionLabel}
mode="list"
openOnInputClick
>
<AutocompleteInputGroup size={size}>
@ -335,64 +311,32 @@ const LimitedStatus = ({
}
const AsyncSearchDemo = () => {
const [searchValue, setSearchValue] = useState('')
const [searchResults, setSearchResults] = useState<Suggestion[]>([])
const [error, setError] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
const { contains } = useAutocompleteFilter()
const abortControllerRef = useRef<AbortController | null>(null)
const [value, setValue] = useState('agent')
const [loading, setLoading] = useState(false)
const [items, setItems] = useState(remoteSuggestions)
const status = (() => {
if (isPending)
return 'Searching remote suggestions…'
useEffect(() => {
setLoading(true)
const timeout = window.setTimeout(() => {
setItems(
value.trim()
? remoteSuggestions.filter(item => item.label.toLowerCase().includes(value.trim().toLowerCase()))
: remoteSuggestions,
)
setLoading(false)
}, 500)
if (error)
return error
if (searchValue === '')
return null
if (searchResults.length === 0)
return `No remote suggestion matches "${searchValue}".`
return `${searchResults.length} remote suggestion${searchResults.length === 1 ? '' : 's'} found`
})()
return () => window.clearTimeout(timeout)
}, [value])
return (
<div className={inputWidth}>
<Autocomplete
items={searchResults}
value={searchValue}
onValueChange={(nextSearchValue) => {
setSearchValue(nextSearchValue)
const controller = new AbortController()
abortControllerRef.current?.abort()
abortControllerRef.current = controller
if (nextSearchValue === '') {
setSearchResults([])
setError(null)
return
}
startTransition(async () => {
setError(null)
const result = await searchSuggestions(remoteSuggestions, nextSearchValue, contains)
if (controller.signal.aborted)
return
startTransition(() => {
setSearchResults(result.items)
setError(result.error)
})
})
}}
items={items}
value={value}
onValueChange={setValue}
itemToStringValue={getSuggestionLabel}
filter={null}
mode="list"
openOnInputClick
>
<AutocompleteInputGroup>
<span className="i-ri-cloud-line ml-2 size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
@ -400,15 +344,16 @@ const AsyncSearchDemo = () => {
<AutocompleteClear />
<AutocompleteTrigger />
</AutocompleteInputGroup>
<AutocompleteContent portalProps={{ hidden: !status }} popupProps={{ 'aria-busy': isPending || undefined }}>
<AutocompleteContent>
<AutocompleteStatus>
{status}
{loading ? 'Loading suggestions…' : `${items.length} remote suggestions`}
</AutocompleteStatus>
<AutocompleteList>
{(item: Suggestion) => (
<SuggestionItem key={item.value} item={item} />
)}
</AutocompleteList>
<AutocompleteEmpty>No remote suggestion. Keep the typed query.</AutocompleteEmpty>
</AutocompleteContent>
</Autocomplete>
</div>
@ -522,7 +467,6 @@ const FuzzyMatchingDemo = () => {
onValueChange={setValue}
filter={contains}
itemToStringValue={getSuggestionLabel}
mode="list"
openOnInputClick
>
<AutocompleteInputGroup>
@ -623,7 +567,6 @@ export const GroupedSuggestions: Story = {
<Autocomplete
items={groupedSuggestions}
itemToStringValue={getSuggestionLabel}
mode="list"
openOnInputClick
>
<AutocompleteInputGroup>
@ -652,7 +595,6 @@ export const LimitResults: Story = {
items={workflowSuggestions}
itemToStringValue={getSuggestionLabel}
limit={5}
mode="list"
openOnInputClick
>
<AutocompleteInputGroup>
@ -685,7 +627,6 @@ export const CommandPalette: Story = {
inline
items={commandGroups}
itemToStringValue={getSuggestionLabel}
mode="list"
autoHighlight="always"
keepHighlight
>
@ -708,7 +649,6 @@ const VirtualizedLongSuggestionsDemo = () => {
<Autocomplete
items={virtualizedSuggestions}
itemToStringValue={getSuggestionLabel}
mode="list"
virtualized
openOnInputClick
onItemHighlighted={(item, details) => {
@ -746,7 +686,6 @@ export const Empty: Story = {
items={tagSuggestions}
itemToStringValue={getSuggestionLabel}
defaultValue="private-release-note"
mode="list"
openOnInputClick
>
<AutocompleteInputGroup>
@ -771,7 +710,7 @@ export const Empty: Story = {
export const DisabledAndReadOnly: Story = {
render: () => (
<div className="flex w-80 flex-col gap-3">
<Autocomplete items={tagSuggestions} itemToStringValue={getSuggestionLabel} defaultValue="feature" mode="list" disabled>
<Autocomplete items={tagSuggestions} itemToStringValue={getSuggestionLabel} defaultValue="feature" disabled>
<AutocompleteInputGroup>
<AutocompleteInput aria-label="Disabled tag autocomplete" />
<AutocompleteClear />
@ -785,7 +724,7 @@ export const DisabledAndReadOnly: Story = {
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
<Autocomplete items={promptCompletions} itemToStringValue={getSuggestionLabel} defaultValue="summarize this conversation" mode="both" readOnly>
<Autocomplete items={promptCompletions} itemToStringValue={getSuggestionLabel} defaultValue="summarize this conversation" readOnly>
<AutocompleteInputGroup>
<AutocompleteInput aria-label="Read-only prompt autocomplete" />
<AutocompleteClear />

View File

@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
import type { Virtualizer } from '@tanstack/react-virtual'
import type { RefObject } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useEffect, useMemo, useRef, useState, useTransition } from 'react'
import { useEffect, useRef, useState } from 'react'
import {
Combobox,
ComboboxChip,
@ -26,7 +26,6 @@ import {
ComboboxStatus,
ComboboxTrigger,
ComboboxValue,
useComboboxFilter,
useComboboxFilteredItems,
} from '.'
import { cn } from '../cn'
@ -179,34 +178,8 @@ const defaultPopupDataSource = dataSourceOptions[1]!
const readOnlyDataSource = dataSourceOptions[2]!
const defaultTool = toolGroups[0]!.items[0]!
const defaultReviewers = [reviewerOptions[0]!, reviewerOptions[1]!]
const defaultAsyncReviewers = [reviewerOptions[1]!]
const defaultTag = tagOptions[2]!
const getOptionLabel = (option: Option) => option.label
async function searchOptions(
options: Option[],
query: string,
filter: (item: string, query: string) => boolean,
): Promise<{ items: Option[], error: string | null }> {
await new Promise(resolve => window.setTimeout(resolve, 450))
if (query === 'will_error') {
return {
items: [],
error: 'Failed to fetch matches. Please try again.',
}
}
return {
items: options.filter(option => (
filter(option.label, query)
|| (option.meta ? filter(option.meta, query) : false)
)),
error: null,
}
}
const renderOptionItem = (option: Option) => (
<ComboboxItem key={option.value} value={option} disabled={option.disabled} className="h-auto min-h-8 py-1.5">
<ComboboxItemText className="flex items-center gap-2 px-0">
@ -375,88 +348,35 @@ const VirtualizedLongListDemo = () => {
}
const AsyncDirectoryDemo = () => {
const [searchResults, setSearchResults] = useState<Option[]>([])
const [selectedValue, setSelectedValue] = useState<Option | null>(null)
const [searchValue, setSearchValue] = useState('')
const [error, setError] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
const { contains } = useComboboxFilter()
const abortControllerRef = useRef<AbortController | null>(null)
const trimmedSearchValue = searchValue.trim()
const items = useMemo(() => {
if (!selectedValue || searchResults.some(option => option.value === selectedValue.value))
return searchResults
const [inputValue, setInputValue] = useState('ma')
const [value, setValue] = useState<Option | null>(null)
const [items, setItems] = useState(directoryOptions.slice(0, 3))
const [loading, setLoading] = useState(false)
return [...searchResults, selectedValue]
}, [searchResults, selectedValue])
useEffect(() => {
setLoading(true)
const timeout = window.setTimeout(() => {
const query = inputValue.trim().toLowerCase()
setItems(
query
? directoryOptions.filter(option => `${option.label} ${option.meta}`.toLowerCase().includes(query))
: directoryOptions.slice(0, 5),
)
setLoading(false)
}, 450)
const status = (() => {
if (isPending)
return 'Searching directory matches…'
if (error)
return error
if (trimmedSearchValue === '')
return selectedValue ? null : 'Start typing to search owners…'
if (searchResults.length === 0)
return `No matches for "${trimmedSearchValue}".`
return `${searchResults.length} owner${searchResults.length === 1 ? '' : 's'} found`
})()
const emptyMessage = trimmedSearchValue === '' || isPending || searchResults.length > 0 || error
? null
: 'Try a different owner search.'
return () => window.clearTimeout(timeout)
}, [inputValue])
return (
<FieldRoot name="owner" className={fieldWidth}>
<FieldLabel>Owner</FieldLabel>
<Combobox
items={items}
itemToStringLabel={getOptionLabel}
filter={null}
value={selectedValue}
onOpenChangeComplete={(open) => {
if (!open && selectedValue)
setSearchResults([selectedValue])
}}
onValueChange={(nextSelectedValue) => {
setSelectedValue(nextSelectedValue)
setSearchValue('')
setError(null)
}}
onInputValueChange={(nextSearchValue, { reason }) => {
setSearchValue(nextSearchValue)
if (nextSearchValue === '') {
setSearchResults([])
setError(null)
return
}
if (reason === 'item-press')
return
const controller = new AbortController()
abortControllerRef.current?.abort()
abortControllerRef.current = controller
startTransition(async () => {
setError(null)
const result = await searchOptions(directoryOptions, nextSearchValue, contains)
if (controller.signal.aborted)
return
startTransition(() => {
setSearchResults(result.items)
setError(result.error)
})
})
}}
items={value && !items.some(item => item.value === value.value) ? [value, ...items] : items}
value={value}
onValueChange={setValue}
inputValue={inputValue}
onInputValueChange={setInputValue}
>
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span aria-hidden className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" />
@ -464,12 +384,12 @@ const AsyncDirectoryDemo = () => {
<ComboboxClear className="mr-0.5" />
<ComboboxInputTrigger className="mr-0" />
</ComboboxInputGroup>
<ComboboxContent popupClassName="w-[420px]" popupProps={{ 'aria-busy': isPending || undefined }}>
<ComboboxContent popupClassName="w-[420px]">
<ComboboxStatus className="border-b border-divider-subtle">
{status}
{loading ? 'Loading directory matches…' : `${items.length} selectable owners`}
</ComboboxStatus>
<ComboboxList>{renderOptionItem}</ComboboxList>
<ComboboxEmpty>{emptyMessage}</ComboboxEmpty>
<ComboboxEmpty>No owner matches this query</ComboboxEmpty>
</ComboboxContent>
</Combobox>
</FieldRoot>
@ -477,111 +397,38 @@ const AsyncDirectoryDemo = () => {
}
const AsyncReviewerDemo = () => {
const [searchResults, setSearchResults] = useState<Option[]>([])
const [selectedValues, setSelectedValues] = useState<Option[]>(defaultAsyncReviewers)
const [searchValue, setSearchValue] = useState('')
const [error, setError] = useState<string | null>(null)
const [blockStartStatus, setBlockStartStatus] = useState(false)
const [isPending, startTransition] = useTransition()
const { contains } = useComboboxFilter()
const abortControllerRef = useRef<AbortController | null>(null)
const selectedValuesRef = useRef<Option[]>(defaultAsyncReviewers)
const trimmedSearchValue = searchValue.trim()
const [inputValue, setInputValue] = useState('ma')
const [value, setValue] = useState<Option[]>([reviewerOptions[1]!])
const [items, setItems] = useState(reviewerOptions.slice(0, 3))
const [loading, setLoading] = useState(false)
const items = useMemo(() => {
if (selectedValues.length === 0)
return searchResults
useEffect(() => {
setLoading(true)
const timeout = window.setTimeout(() => {
const query = inputValue.trim().toLowerCase()
const matches = query
? reviewerOptions.filter(option => `${option.label} ${option.meta}`.toLowerCase().includes(query))
: reviewerOptions
const merged = [...searchResults]
setItems(matches)
setLoading(false)
}, 450)
selectedValues.forEach((selected) => {
if (!searchResults.some(result => result.value === selected.value))
merged.push(selected)
})
return () => window.clearTimeout(timeout)
}, [inputValue])
return merged
}, [searchResults, selectedValues])
const status = (() => {
if (isPending)
return 'Searching reviewer matches…'
if (error)
return error
if (trimmedSearchValue === '' && !blockStartStatus)
return selectedValues.length > 0 ? null : 'Start typing to search reviewers…'
if (searchResults.length === 0 && !blockStartStatus)
return `No matches for "${trimmedSearchValue}".`
return `${searchResults.length} reviewer${searchResults.length === 1 ? '' : 's'} found`
})()
const emptyMessage = trimmedSearchValue === '' || isPending || searchResults.length > 0 || error
? null
: 'Try a different reviewer search.'
const selectedItems = value.filter(selected => !items.some(item => item.value === selected.value))
return (
<FieldRoot name="asyncReviewers" className={fieldWidth}>
<FieldLabel>Async reviewers</FieldLabel>
<Combobox
items={items}
itemToStringLabel={getOptionLabel}
items={[...selectedItems, ...items]}
multiple
filter={null}
value={selectedValues}
onOpenChangeComplete={(open) => {
if (!open) {
setSearchResults(selectedValuesRef.current)
setBlockStartStatus(false)
}
}}
onValueChange={(nextSelectedValues) => {
selectedValuesRef.current = nextSelectedValues
setSelectedValues(nextSelectedValues)
setSearchValue('')
setError(null)
if (nextSelectedValues.length === 0) {
setSearchResults([])
setBlockStartStatus(false)
}
else {
setBlockStartStatus(true)
}
}}
onInputValueChange={(nextSearchValue, { reason }) => {
setSearchValue(nextSearchValue)
const controller = new AbortController()
abortControllerRef.current?.abort()
abortControllerRef.current = controller
if (nextSearchValue === '') {
setSearchResults(selectedValuesRef.current)
setError(null)
setBlockStartStatus(false)
return
}
if (reason === 'item-press')
return
startTransition(async () => {
setError(null)
const result = await searchOptions(reviewerOptions, nextSearchValue, contains)
if (controller.signal.aborted)
return
startTransition(() => {
setSearchResults(result.items)
setError(result.error)
})
})
}}
value={value}
onValueChange={setValue}
inputValue={inputValue}
onInputValueChange={setInputValue}
>
<ComboboxInputGroup className="h-auto min-h-8 items-start py-1">
<ComboboxChips>
@ -600,12 +447,12 @@ const AsyncReviewerDemo = () => {
</ComboboxValue>
</ComboboxChips>
</ComboboxInputGroup>
<ComboboxContent popupClassName="w-[420px]" popupProps={{ 'aria-busy': isPending || undefined }}>
<ComboboxContent popupClassName="w-[420px]">
<ComboboxStatus className="border-b border-divider-subtle">
{status}
{loading ? 'Loading reviewer matches…' : `${items.length} selectable reviewers`}
</ComboboxStatus>
<ComboboxList>{renderOptionItem}</ComboboxList>
<ComboboxEmpty>{emptyMessage}</ComboboxEmpty>
<ComboboxEmpty>No reviewer matches this query</ComboboxEmpty>
</ComboboxContent>
</Combobox>
<FieldDescription>Selected reviewers stay available while async matches change.</FieldDescription>

View File

@ -17,7 +17,7 @@ const Placeholder = ({
loadingFileName,
}: Props) => {
return (
<div className={cn(wrapClassName, 'p-3')}>
<div className={wrapClassName}>
<SkeletonRow>
<div
className="flex h-10 w-10 items-center justify-center gap-2 rounded-[10px] border-[0.5px]

View File

@ -30,7 +30,7 @@ const Installed: FC<Props> = ({
}
return (
<>
<div className="flex flex-col items-start justify-center gap-2 self-stretch px-6 py-3">
<div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
<p className="system-md-regular text-text-secondary">{(isFailed && errMsg) ? errMsg : t(`installModal.${isFailed ? 'installFailedDesc' : 'installedSuccessfullyDesc'}`, { ns: 'plugin' })}</p>
{payload && (
<div className="flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2">

View File

@ -116,7 +116,7 @@ const Installed: FC<Props> = ({
return (
<>
<div className="flex flex-col items-start justify-center gap-2 self-stretch px-6 py-3">
<div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
<div className="system-md-regular text-text-secondary">
<p>{t(`${i18nPrefix}.readyToInstall`, { ns: 'plugin' })}</p>
<p>

View File

@ -106,7 +106,7 @@ const Uploading: FC<Props> = ({
}, [handleUpload])
return (
<>
<div className="flex flex-col items-start justify-center gap-2 self-stretch px-6 py-3">
<div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
<div className="flex items-center gap-1 self-stretch">
<span className="i-ri-loader-2-line size-4 animate-spin-slow text-text-accent" />
<div className="system-md-regular text-text-secondary">