mirror of
https://github.com/langgenius/dify.git
synced 2026-09-21 05:11:22 +08:00
refactor(dify-agent): download Binding files through Agent Stub (#40143)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
autofix-ci[bot]
parent
c094117ca2
commit
916d7e1c96
@@ -1,8 +1,8 @@
|
|||||||
"""Console routes for Agent App and workflow Agent sandbox file access.
|
"""Console routes for Agent App and workflow Agent sandbox file access.
|
||||||
|
|
||||||
The API accepts product-facing Conversation, Build Draft, or Workflow Node
|
The API accepts product-facing Conversation, Build Draft, or Workflow Node
|
||||||
Execution locators and proxies list/read/upload to the agent backend's
|
Execution locators and proxies list/read/download to the agent backend's
|
||||||
``/sandbox`` contract.
|
``/execution-bindings/files`` contract.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -13,7 +13,6 @@ from uuid import UUID
|
|||||||
from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError
|
from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError
|
||||||
from flask_restx import Resource
|
from flask_restx import Resource
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from controllers.common.schema import (
|
from controllers.common.schema import (
|
||||||
query_params_from_model,
|
query_params_from_model,
|
||||||
@@ -21,9 +20,8 @@ from controllers.common.schema import (
|
|||||||
register_response_schema_models,
|
register_response_schema_models,
|
||||||
register_schema_models,
|
register_schema_models,
|
||||||
)
|
)
|
||||||
from controllers.common.session import with_session
|
|
||||||
from controllers.console import console_ns
|
from controllers.console import console_ns
|
||||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
from controllers.console.app.error import AppNotFoundError
|
||||||
from controllers.console.app.wraps import get_app_model
|
from controllers.console.app.wraps import get_app_model
|
||||||
from controllers.console.wraps import (
|
from controllers.console.wraps import (
|
||||||
account_initialization_required,
|
account_initialization_required,
|
||||||
@@ -43,11 +41,17 @@ from services.agent_app_sandbox_service import (
|
|||||||
WorkflowAgentSandboxService,
|
WorkflowAgentSandboxService,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_BINDING_PATH_DESCRIPTION = (
|
||||||
|
"Binding path: relative paths start in Workspace; exact `~` and paths beginning with `~/` start in Home; "
|
||||||
|
"`~user` is an ordinary relative path from Workspace; absolute paths remain absolute; `..` and paths outside "
|
||||||
|
"Workspace are governed by backend isolation, not a Workspace-root restriction"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class AgentSandboxListQuery(BaseModel):
|
class AgentSandboxListQuery(BaseModel):
|
||||||
caller_type: Literal["conversation", "build_draft"]
|
caller_type: Literal["conversation", "build_draft"]
|
||||||
caller_id: str = Field(min_length=1, description="Agent App caller ID")
|
caller_id: str = Field(min_length=1, description="Agent App caller ID")
|
||||||
path: str = Field(default=".", description="Directory path relative to the sandbox workspace")
|
path: str = Field(default=".", description=_BINDING_PATH_DESCRIPTION)
|
||||||
|
|
||||||
|
|
||||||
class AgentSandboxInfoQuery(BaseModel):
|
class AgentSandboxInfoQuery(BaseModel):
|
||||||
@@ -58,28 +62,28 @@ class AgentSandboxInfoQuery(BaseModel):
|
|||||||
class AgentSandboxFileQuery(BaseModel):
|
class AgentSandboxFileQuery(BaseModel):
|
||||||
caller_type: Literal["conversation", "build_draft"]
|
caller_type: Literal["conversation", "build_draft"]
|
||||||
caller_id: str = Field(min_length=1, description="Agent App caller ID")
|
caller_id: str = Field(min_length=1, description="Agent App caller ID")
|
||||||
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
|
path: str = Field(min_length=1, description=_BINDING_PATH_DESCRIPTION)
|
||||||
|
|
||||||
|
|
||||||
class AgentSandboxUploadPayload(BaseModel):
|
class AgentSandboxDownloadPayload(BaseModel):
|
||||||
caller_type: Literal["conversation", "build_draft"]
|
caller_type: Literal["conversation", "build_draft"]
|
||||||
caller_id: str = Field(min_length=1, description="Agent App caller ID")
|
caller_id: str = Field(min_length=1, description="Agent App caller ID")
|
||||||
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
|
path: str = Field(min_length=1, description=_BINDING_PATH_DESCRIPTION)
|
||||||
|
|
||||||
|
|
||||||
class WorkflowAgentSandboxListQuery(BaseModel):
|
class WorkflowAgentSandboxListQuery(BaseModel):
|
||||||
node_execution_id: str = Field(min_length=1, description="Workflow node execution ID")
|
node_execution_id: str = Field(min_length=1, description="Workflow node execution ID")
|
||||||
path: str = Field(default=".", description="Directory path relative to the sandbox workspace")
|
path: str = Field(default=".", description=_BINDING_PATH_DESCRIPTION)
|
||||||
|
|
||||||
|
|
||||||
class WorkflowAgentSandboxFileQuery(BaseModel):
|
class WorkflowAgentSandboxFileQuery(BaseModel):
|
||||||
node_execution_id: str = Field(min_length=1, description="Workflow node execution ID")
|
node_execution_id: str = Field(min_length=1, description="Workflow node execution ID")
|
||||||
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
|
path: str = Field(min_length=1, description=_BINDING_PATH_DESCRIPTION)
|
||||||
|
|
||||||
|
|
||||||
class WorkflowAgentSandboxUploadPayload(BaseModel):
|
class WorkflowAgentSandboxDownloadPayload(BaseModel):
|
||||||
node_execution_id: str = Field(min_length=1, description="Workflow node execution ID")
|
node_execution_id: str = Field(min_length=1, description="Workflow node execution ID")
|
||||||
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
|
path: str = Field(min_length=1, description=_BINDING_PATH_DESCRIPTION)
|
||||||
|
|
||||||
|
|
||||||
class SandboxFileEntryResponse(ResponseModel):
|
class SandboxFileEntryResponse(ResponseModel):
|
||||||
@@ -107,21 +111,21 @@ class SandboxReadResponse(ResponseModel):
|
|||||||
text: str | None = None
|
text: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class SandboxUploadResponse(ResponseModel):
|
class SandboxDownloadResponse(ResponseModel):
|
||||||
url: str
|
url: str
|
||||||
|
|
||||||
|
|
||||||
register_schema_models(
|
register_schema_models(
|
||||||
console_ns,
|
console_ns,
|
||||||
AgentSandboxUploadPayload,
|
AgentSandboxDownloadPayload,
|
||||||
WorkflowAgentSandboxUploadPayload,
|
WorkflowAgentSandboxDownloadPayload,
|
||||||
)
|
)
|
||||||
register_response_schema_models(
|
register_response_schema_models(
|
||||||
console_ns,
|
console_ns,
|
||||||
SandboxInfoResponse,
|
SandboxInfoResponse,
|
||||||
SandboxListResponse,
|
SandboxListResponse,
|
||||||
SandboxReadResponse,
|
SandboxReadResponse,
|
||||||
SandboxUploadResponse,
|
SandboxDownloadResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -152,14 +156,14 @@ class AgentAppSandboxInfoResource(Resource):
|
|||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@with_current_tenant_id
|
@with_current_tenant_id
|
||||||
@with_current_user
|
@with_current_user
|
||||||
@with_session(write=False)
|
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
|
||||||
def get(self, session: Session, current_user: Account, tenant_id: str, agent_id: UUID):
|
service = AgentAppSandboxService()
|
||||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
app_id = service.resolve_app_id(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||||
query = query_params_from_request(AgentSandboxInfoQuery)
|
query = query_params_from_request(AgentSandboxInfoQuery)
|
||||||
try:
|
try:
|
||||||
result = AgentAppSandboxService().get_info(
|
result = service.get_info(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
app_id=app_model.id,
|
app_id=app_id,
|
||||||
agent_id=str(agent_id),
|
agent_id=str(agent_id),
|
||||||
caller_type=query.caller_type,
|
caller_type=query.caller_type,
|
||||||
caller_id=query.caller_id,
|
caller_id=query.caller_id,
|
||||||
@@ -181,14 +185,14 @@ class AgentAppSandboxListResource(Resource):
|
|||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@with_current_tenant_id
|
@with_current_tenant_id
|
||||||
@with_current_user
|
@with_current_user
|
||||||
@with_session(write=False)
|
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
|
||||||
def get(self, session: Session, current_user: Account, tenant_id: str, agent_id: UUID):
|
service = AgentAppSandboxService()
|
||||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
app_id = service.resolve_app_id(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||||
query = query_params_from_request(AgentSandboxListQuery)
|
query = query_params_from_request(AgentSandboxListQuery)
|
||||||
try:
|
try:
|
||||||
result = AgentAppSandboxService().list_files(
|
result = service.list_files(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
app_id=app_model.id,
|
app_id=app_id,
|
||||||
agent_id=str(agent_id),
|
agent_id=str(agent_id),
|
||||||
caller_type=query.caller_type,
|
caller_type=query.caller_type,
|
||||||
caller_id=query.caller_id,
|
caller_id=query.caller_id,
|
||||||
@@ -211,14 +215,14 @@ class AgentAppSandboxReadResource(Resource):
|
|||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@with_current_tenant_id
|
@with_current_tenant_id
|
||||||
@with_current_user
|
@with_current_user
|
||||||
@with_session(write=False)
|
def get(self, current_user: Account, tenant_id: str, agent_id: UUID):
|
||||||
def get(self, session: Session, current_user: Account, tenant_id: str, agent_id: UUID):
|
service = AgentAppSandboxService()
|
||||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
app_id = service.resolve_app_id(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||||
query = query_params_from_request(AgentSandboxFileQuery)
|
query = query_params_from_request(AgentSandboxFileQuery)
|
||||||
try:
|
try:
|
||||||
result = AgentAppSandboxService().read_file(
|
result = service.read_file(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
app_id=app_model.id,
|
app_id=app_id,
|
||||||
agent_id=str(agent_id),
|
agent_id=str(agent_id),
|
||||||
caller_type=query.caller_type,
|
caller_type=query.caller_type,
|
||||||
caller_id=query.caller_id,
|
caller_id=query.caller_id,
|
||||||
@@ -230,32 +234,31 @@ class AgentAppSandboxReadResource(Resource):
|
|||||||
return result.model_dump()
|
return result.model_dump()
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files/upload")
|
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files/download")
|
||||||
class AgentAppSandboxUploadResource(Resource):
|
class AgentAppSandboxDownloadResource(Resource):
|
||||||
@console_ns.doc("upload_agent_app_sandbox_file")
|
@console_ns.doc("download_agent_app_sandbox_file")
|
||||||
@console_ns.doc(description="Upload one Agent App sandbox file and return a signed download URL")
|
@console_ns.doc(description="Create a ToolFile from one Agent App Binding file and return its download URL")
|
||||||
@console_ns.expect(console_ns.models[AgentSandboxUploadPayload.__name__])
|
@console_ns.expect(console_ns.models[AgentSandboxDownloadPayload.__name__])
|
||||||
@console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__])
|
@console_ns.response(200, "Download URL returned", console_ns.models[SandboxDownloadResponse.__name__])
|
||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@with_current_tenant_id
|
@with_current_tenant_id
|
||||||
@with_current_user
|
@with_current_user
|
||||||
@with_session(write=False)
|
@model_validate(AgentSandboxDownloadPayload)
|
||||||
@model_validate(AgentSandboxUploadPayload)
|
|
||||||
def post(
|
def post(
|
||||||
self,
|
self,
|
||||||
req_data: AgentSandboxUploadPayload,
|
req_data: AgentSandboxDownloadPayload,
|
||||||
session: Session,
|
|
||||||
current_user: Account,
|
current_user: Account,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
agent_id: UUID,
|
agent_id: UUID,
|
||||||
):
|
):
|
||||||
app_model = resolve_agent_runtime_app_model(session=session, tenant_id=tenant_id, agent_id=agent_id)
|
service = AgentAppSandboxService()
|
||||||
|
app_id = service.resolve_app_id(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||||
try:
|
try:
|
||||||
result = AgentAppSandboxService().upload_file(
|
result = service.download_file(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
app_id=app_model.id,
|
app_id=app_id,
|
||||||
agent_id=str(agent_id),
|
agent_id=str(agent_id),
|
||||||
caller_type=req_data.caller_type,
|
caller_type=req_data.caller_type,
|
||||||
caller_id=req_data.caller_id,
|
caller_id=req_data.caller_id,
|
||||||
@@ -340,36 +343,41 @@ class WorkflowAgentSandboxReadResource(Resource):
|
|||||||
|
|
||||||
|
|
||||||
@console_ns.route(
|
@console_ns.route(
|
||||||
"/apps/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/sandbox/files/upload"
|
"/apps/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/sandbox/files/download"
|
||||||
)
|
)
|
||||||
class WorkflowAgentSandboxUploadResource(Resource):
|
class WorkflowAgentSandboxDownloadResource(Resource):
|
||||||
@console_ns.doc("upload_workflow_agent_sandbox_file")
|
@console_ns.doc("download_workflow_agent_sandbox_file")
|
||||||
@console_ns.doc(description="Upload one workflow Agent sandbox file and return a signed download URL")
|
@console_ns.doc(description="Create a ToolFile from one workflow Agent Binding file and return its download URL")
|
||||||
@console_ns.expect(console_ns.models[WorkflowAgentSandboxUploadPayload.__name__])
|
@console_ns.expect(console_ns.models[WorkflowAgentSandboxDownloadPayload.__name__])
|
||||||
@console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__])
|
@console_ns.response(200, "Download URL returned", console_ns.models[SandboxDownloadResponse.__name__])
|
||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
@with_current_user
|
||||||
@with_current_tenant_id
|
@with_current_tenant_id
|
||||||
@model_validate(WorkflowAgentSandboxUploadPayload)
|
@model_validate(WorkflowAgentSandboxDownloadPayload)
|
||||||
def post(
|
def post(
|
||||||
self,
|
self,
|
||||||
req_data: WorkflowAgentSandboxUploadPayload,
|
req_data: WorkflowAgentSandboxDownloadPayload,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
app_model: App,
|
current_user: Account,
|
||||||
|
app_id: UUID,
|
||||||
workflow_run_id: UUID,
|
workflow_run_id: UUID,
|
||||||
node_id: str,
|
node_id: str,
|
||||||
):
|
):
|
||||||
|
service = WorkflowAgentSandboxService()
|
||||||
|
resolved_app_id = service.resolve_app_id(tenant_id=tenant_id, app_id=str(app_id))
|
||||||
|
if resolved_app_id is None:
|
||||||
|
raise AppNotFoundError()
|
||||||
try:
|
try:
|
||||||
result = WorkflowAgentSandboxService().upload_file(
|
result = service.download_file(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
app_id=app_model.id,
|
app_id=resolved_app_id,
|
||||||
workflow_run_id=str(workflow_run_id),
|
workflow_run_id=str(workflow_run_id),
|
||||||
node_id=node_id,
|
node_id=node_id,
|
||||||
node_execution_id=req_data.node_execution_id,
|
node_execution_id=req_data.node_execution_id,
|
||||||
|
account_id=current_user.id,
|
||||||
path=req_data.path,
|
path=req_data.path,
|
||||||
session=db.session(),
|
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
return _handle(exc)
|
return _handle(exc)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from typing import Literal
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Resource
|
from flask_restx import Resource
|
||||||
from flask_restx.api import HTTPStatus
|
from flask_restx.api import HTTPStatus
|
||||||
@@ -5,10 +7,12 @@ from pydantic import BaseModel, Field
|
|||||||
from werkzeug.exceptions import Forbidden
|
from werkzeug.exceptions import Forbidden
|
||||||
|
|
||||||
import services
|
import services
|
||||||
|
from core.db.session_factory import session_factory
|
||||||
from core.tools.signature import verify_plugin_file_signature
|
from core.tools.signature import verify_plugin_file_signature
|
||||||
from core.tools.tool_file_manager import ToolFileManager, resolve_extension
|
from core.tools.tool_file_manager import ToolFileManager, resolve_extension
|
||||||
from core.workflow.file_reference import build_file_reference
|
from core.workflow.file_reference import build_file_reference
|
||||||
from fields.file_fields import FileResponse
|
from fields.file_fields import FileResponse
|
||||||
|
from services.account_service import TenantService
|
||||||
|
|
||||||
from ..common.errors import (
|
from ..common.errors import (
|
||||||
FileTooLargeError,
|
FileTooLargeError,
|
||||||
@@ -26,6 +30,7 @@ class PluginUploadQuery(BaseModel):
|
|||||||
sign: str = Field(..., description="HMAC signature")
|
sign: str = Field(..., description="HMAC signature")
|
||||||
tenant_id: str = Field(..., description="Tenant identifier")
|
tenant_id: str = Field(..., description="Tenant identifier")
|
||||||
user_id: str | None = Field(default=None, description="User identifier")
|
user_id: str | None = Field(default=None, description="User identifier")
|
||||||
|
user_from: Literal["account", "end-user"] | None = Field(default=None, description="User identity type")
|
||||||
conversation_id: str | None = Field(default=None, description="Conversation identifier")
|
conversation_id: str | None = Field(default=None, description="Conversation identifier")
|
||||||
|
|
||||||
|
|
||||||
@@ -77,8 +82,20 @@ class PluginUploadFileApi(Resource):
|
|||||||
nonce = args.nonce
|
nonce = args.nonce
|
||||||
sign = args.sign
|
sign = args.sign
|
||||||
tenant_id = args.tenant_id
|
tenant_id = args.tenant_id
|
||||||
user_id = args.user_id
|
if args.user_from == "account":
|
||||||
user = get_user(tenant_id, user_id)
|
if args.user_id is None:
|
||||||
|
raise Forbidden("Invalid request.")
|
||||||
|
with session_factory.create_session() as session:
|
||||||
|
is_tenant_member = TenantService.account_belongs_to_tenant(
|
||||||
|
args.user_id,
|
||||||
|
tenant_id,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
if not is_tenant_member:
|
||||||
|
raise Forbidden("Invalid request.")
|
||||||
|
owner_id = args.user_id
|
||||||
|
else:
|
||||||
|
owner_id = get_user(tenant_id, args.user_id).id
|
||||||
|
|
||||||
filename = file.filename
|
filename = file.filename
|
||||||
mimetype = file.mimetype
|
mimetype = file.mimetype
|
||||||
@@ -90,8 +107,9 @@ class PluginUploadFileApi(Resource):
|
|||||||
filename=filename,
|
filename=filename,
|
||||||
mimetype=mimetype,
|
mimetype=mimetype,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
user_id=user.id,
|
user_id=owner_id,
|
||||||
conversation_id=args.conversation_id,
|
conversation_id=args.conversation_id,
|
||||||
|
user_from=args.user_from,
|
||||||
timestamp=timestamp,
|
timestamp=timestamp,
|
||||||
nonce=nonce,
|
nonce=nonce,
|
||||||
sign=sign,
|
sign=sign,
|
||||||
@@ -100,7 +118,7 @@ class PluginUploadFileApi(Resource):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
tool_file = ToolFileManager().create_file_by_raw(
|
tool_file = ToolFileManager().create_file_by_raw(
|
||||||
user_id=user.id,
|
user_id=owner_id,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
file_binary=file.stream.read(),
|
file_binary=file.stream.read(),
|
||||||
mimetype=mimetype,
|
mimetype=mimetype,
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ class AgentFileRequestHttpError(BaseHTTPException):
|
|||||||
class AgentFileUploadRequestPayload(RequestRequestUploadFile):
|
class AgentFileUploadRequestPayload(RequestRequestUploadFile):
|
||||||
tenant_id: str
|
tenant_id: str
|
||||||
user_id: str
|
user_id: str
|
||||||
|
user_from: Literal["account", "end-user"] | None = None
|
||||||
|
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
@@ -113,13 +114,19 @@ class AgentFileUploadRequestApi(Resource):
|
|||||||
status_code=404,
|
status_code=404,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
user = get_user(tenant.id, payload.user_id)
|
if payload.user_from == "account":
|
||||||
|
if not TenantService.account_belongs_to_tenant(payload.user_id, tenant.id, session=session):
|
||||||
|
raise ValueError("account not found")
|
||||||
|
owner_id = payload.user_id
|
||||||
|
else:
|
||||||
|
owner_id = get_user(tenant.id, payload.user_id).id
|
||||||
upload_uri = get_signed_file_uri_for_plugin(
|
upload_uri = get_signed_file_uri_for_plugin(
|
||||||
filename=payload.filename,
|
filename=payload.filename,
|
||||||
mimetype=payload.mimetype,
|
mimetype=payload.mimetype,
|
||||||
tenant_id=tenant.id,
|
tenant_id=tenant.id,
|
||||||
user_id=user.id,
|
user_id=owner_id,
|
||||||
conversation_id=payload.conversation_id,
|
conversation_id=payload.conversation_id,
|
||||||
|
user_from=payload.user_from,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise AgentFileRequestHttpError(
|
raise AgentFileRequestHttpError(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import hmac
|
|||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
from typing import Literal
|
||||||
from urllib.parse import urlsplit
|
from urllib.parse import urlsplit
|
||||||
|
|
||||||
from configs import dify_config
|
from configs import dify_config
|
||||||
@@ -88,13 +89,27 @@ def verify_tool_file_signature(file_id: str, timestamp: str, nonce: str, sign: s
|
|||||||
|
|
||||||
|
|
||||||
def get_signed_file_uri_for_plugin(
|
def get_signed_file_uri_for_plugin(
|
||||||
filename: str, mimetype: str, tenant_id: str, user_id: str, conversation_id: str | None = None
|
filename: str,
|
||||||
|
mimetype: str,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str,
|
||||||
|
conversation_id: str | None = None,
|
||||||
|
user_from: Literal["account", "end-user"] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Build a signed plugin-upload URI without selecting a network origin."""
|
"""Build a signed plugin-upload URI without selecting a network origin."""
|
||||||
|
|
||||||
timestamp = str(int(time.time()))
|
timestamp = str(int(time.time()))
|
||||||
nonce = os.urandom(16).hex()
|
nonce = os.urandom(16).hex()
|
||||||
data_to_sign = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{conversation_id or ''}|{timestamp}|{nonce}"
|
data_to_sign = _plugin_upload_signature_payload(
|
||||||
|
filename=filename,
|
||||||
|
mimetype=mimetype,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
timestamp=timestamp,
|
||||||
|
nonce=nonce,
|
||||||
|
user_from=user_from,
|
||||||
|
)
|
||||||
sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest()
|
sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest()
|
||||||
encoded_sign = base64.urlsafe_b64encode(sign).decode()
|
encoded_sign = base64.urlsafe_b64encode(sign).decode()
|
||||||
query_params = {
|
query_params = {
|
||||||
@@ -106,6 +121,8 @@ def get_signed_file_uri_for_plugin(
|
|||||||
}
|
}
|
||||||
if conversation_id:
|
if conversation_id:
|
||||||
query_params["conversation_id"] = conversation_id
|
query_params["conversation_id"] = conversation_id
|
||||||
|
if user_from is not None:
|
||||||
|
query_params["user_from"] = user_from
|
||||||
query = urllib.parse.urlencode(query_params)
|
query = urllib.parse.urlencode(query_params)
|
||||||
return f"/files/upload/for-plugin?{query}"
|
return f"/files/upload/for-plugin?{query}"
|
||||||
|
|
||||||
@@ -117,13 +134,23 @@ def verify_plugin_file_signature(
|
|||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
user_id: str,
|
user_id: str,
|
||||||
conversation_id: str | None = None,
|
conversation_id: str | None = None,
|
||||||
|
user_from: Literal["account", "end-user"] | None = None,
|
||||||
timestamp: str,
|
timestamp: str,
|
||||||
nonce: str,
|
nonce: str,
|
||||||
sign: str,
|
sign: str,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Verify the signature used by the plugin-facing file upload endpoint."""
|
"""Verify the signature used by the plugin-facing file upload endpoint."""
|
||||||
|
|
||||||
data_to_sign = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{conversation_id or ''}|{timestamp}|{nonce}"
|
data_to_sign = _plugin_upload_signature_payload(
|
||||||
|
filename=filename,
|
||||||
|
mimetype=mimetype,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
user_id=user_id,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
timestamp=timestamp,
|
||||||
|
nonce=nonce,
|
||||||
|
user_from=user_from,
|
||||||
|
)
|
||||||
recalculated_sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest()
|
recalculated_sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest()
|
||||||
recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode()
|
recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode()
|
||||||
|
|
||||||
@@ -132,3 +159,27 @@ def verify_plugin_file_signature(
|
|||||||
|
|
||||||
current_time = int(time.time())
|
current_time = int(time.time())
|
||||||
return current_time - int(timestamp) <= dify_config.FILES_ACCESS_TIMEOUT
|
return current_time - int(timestamp) <= dify_config.FILES_ACCESS_TIMEOUT
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_upload_signature_payload(
|
||||||
|
*,
|
||||||
|
filename: str,
|
||||||
|
mimetype: str,
|
||||||
|
tenant_id: str,
|
||||||
|
user_id: str,
|
||||||
|
conversation_id: str | None,
|
||||||
|
timestamp: str,
|
||||||
|
nonce: str,
|
||||||
|
user_from: Literal["account", "end-user"] | None,
|
||||||
|
) -> str:
|
||||||
|
"""Build the compatible upload signature payload with optional identity ownership.
|
||||||
|
|
||||||
|
Omitting ``user_from`` preserves the legacy payload. When present, the
|
||||||
|
identity kind is appended and HMAC-protected so account/end-user ownership
|
||||||
|
cannot be altered.
|
||||||
|
"""
|
||||||
|
|
||||||
|
payload = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{conversation_id or ''}|{timestamp}|{nonce}"
|
||||||
|
if user_from is not None:
|
||||||
|
payload = f"{payload}|{user_from}"
|
||||||
|
return payload
|
||||||
|
|||||||
@@ -1274,7 +1274,7 @@ List a directory in an Agent App conversation sandbox
|
|||||||
| agent_id | path | Agent ID | Yes | string (uuid) |
|
| agent_id | path | Agent ID | Yes | string (uuid) |
|
||||||
| caller_id | query | Agent App caller ID | Yes | string |
|
| caller_id | query | Agent App caller ID | Yes | string |
|
||||||
| caller_type | query | | Yes | string, <br>**Available values:** "build_draft", "conversation" |
|
| caller_type | query | | Yes | string, <br>**Available values:** "build_draft", "conversation" |
|
||||||
| path | query | Directory path relative to the sandbox workspace | No | string, <br>**Default:** . |
|
| path | query | Binding path: relative paths start in Workspace; exact `~` and paths beginning with `~/` start in Home; `~user` is an ordinary relative path from Workspace; absolute paths remain absolute; `..` and paths outside Workspace are governed by backend isolation, not a Workspace-root restriction | No | string, <br>**Default:** . |
|
||||||
|
|
||||||
#### Responses
|
#### Responses
|
||||||
|
|
||||||
@@ -1282,26 +1282,8 @@ List a directory in an Agent App conversation sandbox
|
|||||||
| ---- | ----------- | ------ |
|
| ---- | ----------- | ------ |
|
||||||
| 200 | Listing returned | **application/json**: [SandboxListResponse](#sandboxlistresponse)<br> |
|
| 200 | Listing returned | **application/json**: [SandboxListResponse](#sandboxlistresponse)<br> |
|
||||||
|
|
||||||
### [GET] /agent/{agent_id}/sandbox/files/read
|
### [POST] /agent/{agent_id}/sandbox/files/download
|
||||||
Read a text/binary preview file in an Agent App conversation sandbox
|
Create a ToolFile from one Agent App Binding file and return its download URL
|
||||||
|
|
||||||
#### Parameters
|
|
||||||
|
|
||||||
| Name | Located in | Description | Required | Schema |
|
|
||||||
| ---- | ---------- | ----------- | -------- | ------ |
|
|
||||||
| agent_id | path | Agent ID | Yes | string (uuid) |
|
|
||||||
| caller_id | query | Agent App caller ID | Yes | string |
|
|
||||||
| caller_type | query | | Yes | string, <br>**Available values:** "build_draft", "conversation" |
|
|
||||||
| path | query | File path relative to the sandbox workspace | Yes | string |
|
|
||||||
|
|
||||||
#### Responses
|
|
||||||
|
|
||||||
| Code | Description | Schema |
|
|
||||||
| ---- | ----------- | ------ |
|
|
||||||
| 200 | Preview returned | **application/json**: [SandboxReadResponse](#sandboxreadresponse)<br> |
|
|
||||||
|
|
||||||
### [POST] /agent/{agent_id}/sandbox/files/upload
|
|
||||||
Upload one Agent App sandbox file and return a signed download URL
|
|
||||||
|
|
||||||
#### Parameters
|
#### Parameters
|
||||||
|
|
||||||
@@ -1313,13 +1295,31 @@ Upload one Agent App sandbox file and return a signed download URL
|
|||||||
|
|
||||||
| Required | Schema |
|
| Required | Schema |
|
||||||
| -------- | ------ |
|
| -------- | ------ |
|
||||||
| Yes | **application/json**: [AgentSandboxUploadPayload](#agentsandboxuploadpayload)<br> |
|
| Yes | **application/json**: [AgentSandboxDownloadPayload](#agentsandboxdownloadpayload)<br> |
|
||||||
|
|
||||||
#### Responses
|
#### Responses
|
||||||
|
|
||||||
| Code | Description | Schema |
|
| Code | Description | Schema |
|
||||||
| ---- | ----------- | ------ |
|
| ---- | ----------- | ------ |
|
||||||
| 200 | Uploaded | **application/json**: [SandboxUploadResponse](#sandboxuploadresponse)<br> |
|
| 200 | Download URL returned | **application/json**: [SandboxDownloadResponse](#sandboxdownloadresponse)<br> |
|
||||||
|
|
||||||
|
### [GET] /agent/{agent_id}/sandbox/files/read
|
||||||
|
Read a text/binary preview file in an Agent App conversation sandbox
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Name | Located in | Description | Required | Schema |
|
||||||
|
| ---- | ---------- | ----------- | -------- | ------ |
|
||||||
|
| agent_id | path | Agent ID | Yes | string (uuid) |
|
||||||
|
| caller_id | query | Agent App caller ID | Yes | string |
|
||||||
|
| caller_type | query | | Yes | string, <br>**Available values:** "build_draft", "conversation" |
|
||||||
|
| path | query | Binding path: relative paths start in Workspace; exact `~` and paths beginning with `~/` start in Home; `~user` is an ordinary relative path from Workspace; absolute paths remain absolute; `..` and paths outside Workspace are governed by backend isolation, not a Workspace-root restriction | Yes | string |
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description | Schema |
|
||||||
|
| ---- | ----------- | ------ |
|
||||||
|
| 200 | Preview returned | **application/json**: [SandboxReadResponse](#sandboxreadresponse)<br> |
|
||||||
|
|
||||||
### [POST] /agent/{agent_id}/skills/upload
|
### [POST] /agent/{agent_id}/skills/upload
|
||||||
Upload + standardize a Skill into an Agent App drive
|
Upload + standardize a Skill into an Agent App drive
|
||||||
@@ -3822,7 +3822,7 @@ List a directory in a workflow Agent node sandbox
|
|||||||
| node_id | path | Workflow Agent node ID | Yes | string |
|
| node_id | path | Workflow Agent node ID | Yes | string |
|
||||||
| workflow_run_id | path | Workflow run ID | Yes | string (uuid) |
|
| workflow_run_id | path | Workflow run ID | Yes | string (uuid) |
|
||||||
| node_execution_id | query | Workflow node execution ID | Yes | string |
|
| node_execution_id | query | Workflow node execution ID | Yes | string |
|
||||||
| path | query | Directory path relative to the sandbox workspace | No | string, <br>**Default:** . |
|
| path | query | Binding path: relative paths start in Workspace; exact `~` and paths beginning with `~/` start in Home; `~user` is an ordinary relative path from Workspace; absolute paths remain absolute; `..` and paths outside Workspace are governed by backend isolation, not a Workspace-root restriction | No | string, <br>**Default:** . |
|
||||||
|
|
||||||
#### Responses
|
#### Responses
|
||||||
|
|
||||||
@@ -3830,27 +3830,8 @@ List a directory in a workflow Agent node sandbox
|
|||||||
| ---- | ----------- | ------ |
|
| ---- | ----------- | ------ |
|
||||||
| 200 | Listing returned | **application/json**: [SandboxListResponse](#sandboxlistresponse)<br> |
|
| 200 | Listing returned | **application/json**: [SandboxListResponse](#sandboxlistresponse)<br> |
|
||||||
|
|
||||||
### [GET] /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/read
|
### [POST] /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/download
|
||||||
Read a text/binary preview file in a workflow Agent node sandbox
|
Create a ToolFile from one workflow Agent Binding file and return its download URL
|
||||||
|
|
||||||
#### Parameters
|
|
||||||
|
|
||||||
| Name | Located in | Description | Required | Schema |
|
|
||||||
| ---- | ---------- | ----------- | -------- | ------ |
|
|
||||||
| app_id | path | Application ID | Yes | string (uuid) |
|
|
||||||
| node_id | path | Workflow Agent node ID | Yes | string |
|
|
||||||
| workflow_run_id | path | Workflow run ID | Yes | string (uuid) |
|
|
||||||
| node_execution_id | query | Workflow node execution ID | Yes | string |
|
|
||||||
| path | query | File path relative to the sandbox workspace | Yes | string |
|
|
||||||
|
|
||||||
#### Responses
|
|
||||||
|
|
||||||
| Code | Description | Schema |
|
|
||||||
| ---- | ----------- | ------ |
|
|
||||||
| 200 | Preview returned | **application/json**: [SandboxReadResponse](#sandboxreadresponse)<br> |
|
|
||||||
|
|
||||||
### [POST] /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/upload
|
|
||||||
Upload one workflow Agent sandbox file and return a signed download URL
|
|
||||||
|
|
||||||
#### Parameters
|
#### Parameters
|
||||||
|
|
||||||
@@ -3864,13 +3845,32 @@ Upload one workflow Agent sandbox file and return a signed download URL
|
|||||||
|
|
||||||
| Required | Schema |
|
| Required | Schema |
|
||||||
| -------- | ------ |
|
| -------- | ------ |
|
||||||
| Yes | **application/json**: [WorkflowAgentSandboxUploadPayload](#workflowagentsandboxuploadpayload)<br> |
|
| Yes | **application/json**: [WorkflowAgentSandboxDownloadPayload](#workflowagentsandboxdownloadpayload)<br> |
|
||||||
|
|
||||||
#### Responses
|
#### Responses
|
||||||
|
|
||||||
| Code | Description | Schema |
|
| Code | Description | Schema |
|
||||||
| ---- | ----------- | ------ |
|
| ---- | ----------- | ------ |
|
||||||
| 200 | Uploaded | **application/json**: [SandboxUploadResponse](#sandboxuploadresponse)<br> |
|
| 200 | Download URL returned | **application/json**: [SandboxDownloadResponse](#sandboxdownloadresponse)<br> |
|
||||||
|
|
||||||
|
### [GET] /apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/read
|
||||||
|
Read a text/binary preview file in a workflow Agent node sandbox
|
||||||
|
|
||||||
|
#### Parameters
|
||||||
|
|
||||||
|
| Name | Located in | Description | Required | Schema |
|
||||||
|
| ---- | ---------- | ----------- | -------- | ------ |
|
||||||
|
| app_id | path | Application ID | Yes | string (uuid) |
|
||||||
|
| node_id | path | Workflow Agent node ID | Yes | string |
|
||||||
|
| workflow_run_id | path | Workflow run ID | Yes | string (uuid) |
|
||||||
|
| node_execution_id | query | Workflow node execution ID | Yes | string |
|
||||||
|
| path | query | Binding path: relative paths start in Workspace; exact `~` and paths beginning with `~/` start in Home; `~user` is an ordinary relative path from Workspace; absolute paths remain absolute; `..` and paths outside Workspace are governed by backend isolation, not a Workspace-root restriction | Yes | string |
|
||||||
|
|
||||||
|
#### Responses
|
||||||
|
|
||||||
|
| Code | Description | Schema |
|
||||||
|
| ---- | ----------- | ------ |
|
||||||
|
| 200 | Preview returned | **application/json**: [SandboxReadResponse](#sandboxreadresponse)<br> |
|
||||||
|
|
||||||
### [GET] /apps/{app_id}/workflow/comments
|
### [GET] /apps/{app_id}/workflow/comments
|
||||||
**Get all comments for a workflow**
|
**Get all comments for a workflow**
|
||||||
@@ -14703,6 +14703,14 @@ section may be empty, which is how callers express "no knowledge layer".
|
|||||||
| workflow_id | string | | No |
|
| workflow_id | string | | No |
|
||||||
| workflow_node_id | string | | No |
|
| workflow_node_id | string | | No |
|
||||||
|
|
||||||
|
#### AgentSandboxDownloadPayload
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| caller_id | string | Agent App caller ID | Yes |
|
||||||
|
| caller_type | string, <br>**Available values:** "build_draft", "conversation" | *Enum:* `"build_draft"`, `"conversation"` | Yes |
|
||||||
|
| path | string | Binding path: relative paths start in Workspace; exact `~` and paths beginning with `~/` start in Home; `~user` is an ordinary relative path from Workspace; absolute paths remain absolute; `..` and paths outside Workspace are governed by backend isolation, not a Workspace-root restriction | Yes |
|
||||||
|
|
||||||
#### AgentSandboxProviderConfig
|
#### AgentSandboxProviderConfig
|
||||||
|
|
||||||
| Name | Type | Description | Required |
|
| Name | Type | Description | Required |
|
||||||
@@ -14712,14 +14720,6 @@ section may be empty, which is how callers express "no knowledge layer".
|
|||||||
| image | string | | No |
|
| image | string | | No |
|
||||||
| working_dir | string | | No |
|
| working_dir | string | | No |
|
||||||
|
|
||||||
#### AgentSandboxUploadPayload
|
|
||||||
|
|
||||||
| Name | Type | Description | Required |
|
|
||||||
| ---- | ---- | ----------- | -------- |
|
|
||||||
| caller_id | string | Agent App caller ID | Yes |
|
|
||||||
| caller_type | string, <br>**Available values:** "build_draft", "conversation" | *Enum:* `"build_draft"`, `"conversation"` | Yes |
|
|
||||||
| path | string | File path relative to the sandbox workspace | Yes |
|
|
||||||
|
|
||||||
#### AgentScope
|
#### AgentScope
|
||||||
|
|
||||||
Visibility and lifecycle scope of an Agent record.
|
Visibility and lifecycle scope of an Agent record.
|
||||||
@@ -21522,6 +21522,12 @@ Whitelist scopes accepted by RBAC app and dataset access config APIs.
|
|||||||
| ---- | ---- | ----------- | -------- |
|
| ---- | ---- | ----------- | -------- |
|
||||||
| SSOProtocol | string | | |
|
| SSOProtocol | string | | |
|
||||||
|
|
||||||
|
#### SandboxDownloadResponse
|
||||||
|
|
||||||
|
| Name | Type | Description | Required |
|
||||||
|
| ---- | ---- | ----------- | -------- |
|
||||||
|
| url | string | | Yes |
|
||||||
|
|
||||||
#### SandboxFileEntryResponse
|
#### SandboxFileEntryResponse
|
||||||
|
|
||||||
| Name | Type | Description | Required |
|
| Name | Type | Description | Required |
|
||||||
@@ -21555,12 +21561,6 @@ Whitelist scopes accepted by RBAC app and dataset access config APIs.
|
|||||||
| text | string | | No |
|
| text | string | | No |
|
||||||
| truncated | boolean | | Yes |
|
| truncated | boolean | | Yes |
|
||||||
|
|
||||||
#### SandboxUploadResponse
|
|
||||||
|
|
||||||
| Name | Type | Description | Required |
|
|
||||||
| ---- | ---- | ----------- | -------- |
|
|
||||||
| url | string | | Yes |
|
|
||||||
|
|
||||||
#### SavedMessageCreatePayload
|
#### SavedMessageCreatePayload
|
||||||
|
|
||||||
| Name | Type | Description | Required |
|
| Name | Type | Description | Required |
|
||||||
@@ -23367,12 +23367,12 @@ How a workflow node is bound to an Agent.
|
|||||||
| variant | string | | Yes |
|
| variant | string | | Yes |
|
||||||
| workflow_id | string | | No |
|
| workflow_id | string | | No |
|
||||||
|
|
||||||
#### WorkflowAgentSandboxUploadPayload
|
#### WorkflowAgentSandboxDownloadPayload
|
||||||
|
|
||||||
| Name | Type | Description | Required |
|
| Name | Type | Description | Required |
|
||||||
| ---- | ---- | ----------- | -------- |
|
| ---- | ---- | ----------- | -------- |
|
||||||
| node_execution_id | string | Workflow node execution ID | Yes |
|
| node_execution_id | string | Workflow node execution ID | Yes |
|
||||||
| path | string | File path relative to the sandbox workspace | Yes |
|
| path | string | Binding path: relative paths start in Workspace; exact `~` and paths beginning with `~/` start in Home; `~user` is an ordinary relative path from Workspace; absolute paths remain absolute; `..` and paths outside Workspace are governed by backend isolation, not a Workspace-root restriction | Yes |
|
||||||
|
|
||||||
#### WorkflowAppLogPaginationResponse
|
#### WorkflowAppLogPaginationResponse
|
||||||
|
|
||||||
|
|||||||
@@ -4,23 +4,26 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from typing import Any, Literal, cast
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal, cast
|
||||||
|
|
||||||
from dify_agent.client import Client
|
from dify_agent.client import Client
|
||||||
from dify_agent.layers.execution_context import (
|
from dify_agent.layers.execution_context import (
|
||||||
DifyExecutionContextAgentConfigVersionKind,
|
DifyExecutionContextAgentConfigVersionKind,
|
||||||
DifyExecutionContextLayerConfig,
|
DifyExecutionContextLayerConfig,
|
||||||
)
|
)
|
||||||
from dify_agent.protocol import WorkspaceListResponse, WorkspaceReadResponse, WorkspaceUploadRequest
|
from dify_agent.protocol import (
|
||||||
|
BindingFileDownloadRequest,
|
||||||
|
BindingFileListResponse,
|
||||||
|
BindingFileReadResponse,
|
||||||
|
)
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from configs import dify_config
|
from configs import dify_config
|
||||||
from core.app.file_access import DatabaseFileAccessController
|
|
||||||
from core.app.workflow.file_runtime import DifyWorkflowFileRuntime
|
|
||||||
from core.db.session_factory import session_factory
|
from core.db.session_factory import session_factory
|
||||||
from factories import file_factory
|
from core.tools.signature import bind_file_uri
|
||||||
from models.agent import (
|
from models.agent import (
|
||||||
Agent,
|
Agent,
|
||||||
AgentConfigDraft,
|
AgentConfigDraft,
|
||||||
@@ -28,10 +31,11 @@ from models.agent import (
|
|||||||
AgentWorkspaceBinding,
|
AgentWorkspaceBinding,
|
||||||
AgentWorkspaceOwnerType,
|
AgentWorkspaceOwnerType,
|
||||||
)
|
)
|
||||||
from models.model import App, Conversation
|
from models.model import App, AppMode, Conversation
|
||||||
from models.workflow import WorkflowNodeExecutionModel
|
from models.workflow import WorkflowNodeExecutionModel
|
||||||
from services.agent.roster_service import AgentRosterService
|
from services.agent.roster_service import AgentRosterService
|
||||||
from services.agent.workspace_service import AgentWorkspaceService, WorkspaceOwnerScope
|
from services.agent.workspace_service import AgentWorkspaceService, WorkspaceOwnerScope
|
||||||
|
from services.file_request_service import FileRequestService
|
||||||
|
|
||||||
|
|
||||||
class AgentSandboxInspectorError(Exception):
|
class AgentSandboxInspectorError(Exception):
|
||||||
@@ -50,14 +54,37 @@ class AgentSandboxInfo(BaseModel):
|
|||||||
workspace_cwd: str
|
workspace_cwd: str
|
||||||
|
|
||||||
|
|
||||||
class AgentSandboxUploadDownload(BaseModel):
|
class AgentSandboxDownload(BaseModel):
|
||||||
url: str
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _ResolvedBinding:
|
||||||
|
"""Detached scalar Binding data safe to carry beyond its read transaction.
|
||||||
|
|
||||||
|
Resolvers must end the transaction before Dify Agent network I/O; ORM and
|
||||||
|
session-bound objects never cross that boundary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
backend_binding_ref: str
|
||||||
|
agent_id: str
|
||||||
|
agent_config_version_id: str
|
||||||
|
agent_config_version_kind: str
|
||||||
|
|
||||||
|
|
||||||
class AgentAppSandboxService:
|
class AgentAppSandboxService:
|
||||||
def __init__(self, *, client_factory: Callable[[], Client] | None = None) -> None:
|
def __init__(self, *, client_factory: Callable[[], Client] | None = None) -> None:
|
||||||
self._client_factory = client_factory or _default_client_factory
|
self._client_factory = client_factory or _default_client_factory
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_app_id(*, tenant_id: str, agent_id: str) -> str:
|
||||||
|
with session_factory.create_session() as session:
|
||||||
|
app = AgentRosterService(session).get_agent_runtime_app_model(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
)
|
||||||
|
return app.id
|
||||||
|
|
||||||
def get_info(
|
def get_info(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -88,7 +115,7 @@ class AgentAppSandboxService:
|
|||||||
caller_id: str,
|
caller_id: str,
|
||||||
account_id: str,
|
account_id: str,
|
||||||
path: str,
|
path: str,
|
||||||
) -> WorkspaceListResponse:
|
) -> BindingFileListResponse:
|
||||||
binding = self._resolve_binding(
|
binding = self._resolve_binding(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
app_id=app_id,
|
app_id=app_id,
|
||||||
@@ -98,7 +125,7 @@ class AgentAppSandboxService:
|
|||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
)
|
)
|
||||||
with self._client_factory() as client:
|
with self._client_factory() as client:
|
||||||
return client.list_workspace_files_sync(binding.backend_binding_ref, path)
|
return client.list_binding_files_sync(binding.backend_binding_ref, path)
|
||||||
|
|
||||||
def read_file(
|
def read_file(
|
||||||
self,
|
self,
|
||||||
@@ -110,7 +137,7 @@ class AgentAppSandboxService:
|
|||||||
caller_id: str,
|
caller_id: str,
|
||||||
account_id: str,
|
account_id: str,
|
||||||
path: str,
|
path: str,
|
||||||
) -> WorkspaceReadResponse:
|
) -> BindingFileReadResponse:
|
||||||
binding = self._resolve_binding(
|
binding = self._resolve_binding(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
app_id=app_id,
|
app_id=app_id,
|
||||||
@@ -120,9 +147,9 @@ class AgentAppSandboxService:
|
|||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
)
|
)
|
||||||
with self._client_factory() as client:
|
with self._client_factory() as client:
|
||||||
return client.read_workspace_file_sync(binding.backend_binding_ref, path)
|
return client.read_binding_file_sync(binding.backend_binding_ref, path)
|
||||||
|
|
||||||
def upload_file(
|
def download_file(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
@@ -132,7 +159,7 @@ class AgentAppSandboxService:
|
|||||||
caller_id: str,
|
caller_id: str,
|
||||||
account_id: str,
|
account_id: str,
|
||||||
path: str,
|
path: str,
|
||||||
) -> AgentSandboxUploadDownload:
|
) -> AgentSandboxDownload:
|
||||||
binding = self._resolve_binding(
|
binding = self._resolve_binding(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
app_id=app_id,
|
app_id=app_id,
|
||||||
@@ -142,26 +169,28 @@ class AgentAppSandboxService:
|
|||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
)
|
)
|
||||||
with self._client_factory() as client:
|
with self._client_factory() as client:
|
||||||
uploaded = client.upload_workspace_file_sync(
|
downloaded = client.download_binding_file_sync(
|
||||||
WorkspaceUploadRequest(
|
BindingFileDownloadRequest(
|
||||||
backend_binding_ref=binding.backend_binding_ref,
|
backend_binding_ref=binding.backend_binding_ref,
|
||||||
path=path,
|
path=path,
|
||||||
execution_context=DifyExecutionContextLayerConfig(
|
execution_context=DifyExecutionContextLayerConfig(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
|
user_id=account_id,
|
||||||
|
user_from="account",
|
||||||
app_id=app_id,
|
app_id=app_id,
|
||||||
conversation_id=caller_id if caller_type == "conversation" else None,
|
conversation_id=caller_id if caller_type == "conversation" else None,
|
||||||
agent_id=agent_id,
|
agent_id=agent_id,
|
||||||
agent_config_version_id=binding.agent_config_version_id,
|
agent_config_version_id=binding.agent_config_version_id,
|
||||||
agent_config_version_kind=cast(
|
agent_config_version_kind=cast(
|
||||||
DifyExecutionContextAgentConfigVersionKind,
|
DifyExecutionContextAgentConfigVersionKind,
|
||||||
binding.agent_config_version_kind.value,
|
binding.agent_config_version_kind,
|
||||||
),
|
),
|
||||||
agent_mode="agent_app",
|
agent_mode="agent_app",
|
||||||
invoke_from="debugger",
|
invoke_from="debugger",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return _upload_download_response(tenant_id=tenant_id, file_mapping=uploaded.file.model_dump(mode="python"))
|
return _download_response(tenant_id=tenant_id, account_id=account_id, reference=downloaded.reference)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_binding(
|
def _resolve_binding(
|
||||||
@@ -172,7 +201,7 @@ class AgentAppSandboxService:
|
|||||||
caller_type: Literal["conversation", "build_draft"],
|
caller_type: Literal["conversation", "build_draft"],
|
||||||
caller_id: str,
|
caller_id: str,
|
||||||
account_id: str,
|
account_id: str,
|
||||||
) -> AgentWorkspaceBinding:
|
) -> _ResolvedBinding:
|
||||||
with session_factory.create_session() as session:
|
with session_factory.create_session() as session:
|
||||||
caller: AgentConfigDraft | Conversation | None
|
caller: AgentConfigDraft | Conversation | None
|
||||||
if caller_type == "build_draft":
|
if caller_type == "build_draft":
|
||||||
@@ -236,14 +265,25 @@ class AgentAppSandboxService:
|
|||||||
"this caller has no active Agent Workspace Binding",
|
"this caller has no active Agent Workspace Binding",
|
||||||
status_code=404,
|
status_code=404,
|
||||||
)
|
)
|
||||||
session.expunge(binding)
|
return _binding_value(binding)
|
||||||
return binding
|
|
||||||
|
|
||||||
|
|
||||||
class WorkflowAgentSandboxService:
|
class WorkflowAgentSandboxService:
|
||||||
def __init__(self, *, client_factory: Callable[[], Client] | None = None) -> None:
|
def __init__(self, *, client_factory: Callable[[], Client] | None = None) -> None:
|
||||||
self._client_factory = client_factory or _default_client_factory
|
self._client_factory = client_factory or _default_client_factory
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_app_id(*, tenant_id: str, app_id: str) -> str | None:
|
||||||
|
with session_factory.create_session() as session:
|
||||||
|
return session.scalar(
|
||||||
|
select(App.id).where(
|
||||||
|
App.id == app_id,
|
||||||
|
App.tenant_id == tenant_id,
|
||||||
|
App.status == "normal",
|
||||||
|
App.mode.in_((AppMode.ADVANCED_CHAT.value, AppMode.WORKFLOW.value)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def list_files(
|
def list_files(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -254,7 +294,7 @@ class WorkflowAgentSandboxService:
|
|||||||
node_execution_id: str,
|
node_execution_id: str,
|
||||||
path: str,
|
path: str,
|
||||||
session: Session,
|
session: Session,
|
||||||
) -> WorkspaceListResponse:
|
) -> BindingFileListResponse:
|
||||||
binding = self._resolve_binding(
|
binding = self._resolve_binding(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
app_id=app_id,
|
app_id=app_id,
|
||||||
@@ -264,7 +304,7 @@ class WorkflowAgentSandboxService:
|
|||||||
session=session,
|
session=session,
|
||||||
)
|
)
|
||||||
with self._client_factory() as client:
|
with self._client_factory() as client:
|
||||||
return client.list_workspace_files_sync(binding.backend_binding_ref, path)
|
return client.list_binding_files_sync(binding.backend_binding_ref, path)
|
||||||
|
|
||||||
def read_file(
|
def read_file(
|
||||||
self,
|
self,
|
||||||
@@ -276,7 +316,7 @@ class WorkflowAgentSandboxService:
|
|||||||
node_execution_id: str,
|
node_execution_id: str,
|
||||||
path: str,
|
path: str,
|
||||||
session: Session,
|
session: Session,
|
||||||
) -> WorkspaceReadResponse:
|
) -> BindingFileReadResponse:
|
||||||
binding = self._resolve_binding(
|
binding = self._resolve_binding(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
app_id=app_id,
|
app_id=app_id,
|
||||||
@@ -286,9 +326,9 @@ class WorkflowAgentSandboxService:
|
|||||||
session=session,
|
session=session,
|
||||||
)
|
)
|
||||||
with self._client_factory() as client:
|
with self._client_factory() as client:
|
||||||
return client.read_workspace_file_sync(binding.backend_binding_ref, path)
|
return client.read_binding_file_sync(binding.backend_binding_ref, path)
|
||||||
|
|
||||||
def upload_file(
|
def download_file(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
@@ -296,39 +336,43 @@ class WorkflowAgentSandboxService:
|
|||||||
workflow_run_id: str,
|
workflow_run_id: str,
|
||||||
node_id: str,
|
node_id: str,
|
||||||
node_execution_id: str,
|
node_execution_id: str,
|
||||||
|
account_id: str,
|
||||||
path: str,
|
path: str,
|
||||||
session: Session,
|
) -> AgentSandboxDownload:
|
||||||
) -> AgentSandboxUploadDownload:
|
with session_factory.create_session() as session:
|
||||||
binding = self._resolve_binding(
|
binding = self._resolve_binding(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
app_id=app_id,
|
app_id=app_id,
|
||||||
workflow_run_id=workflow_run_id,
|
workflow_run_id=workflow_run_id,
|
||||||
node_id=node_id,
|
node_id=node_id,
|
||||||
node_execution_id=node_execution_id,
|
node_execution_id=node_execution_id,
|
||||||
session=session,
|
session=session,
|
||||||
)
|
)
|
||||||
with self._client_factory() as client:
|
with self._client_factory() as client:
|
||||||
uploaded = client.upload_workspace_file_sync(
|
downloaded = client.download_binding_file_sync(
|
||||||
WorkspaceUploadRequest(
|
BindingFileDownloadRequest(
|
||||||
backend_binding_ref=binding.backend_binding_ref,
|
backend_binding_ref=binding.backend_binding_ref,
|
||||||
path=path,
|
path=path,
|
||||||
execution_context=DifyExecutionContextLayerConfig(
|
execution_context=DifyExecutionContextLayerConfig(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
|
user_id=account_id,
|
||||||
|
user_from="account",
|
||||||
app_id=app_id,
|
app_id=app_id,
|
||||||
workflow_run_id=workflow_run_id,
|
workflow_run_id=workflow_run_id,
|
||||||
node_id=node_id,
|
node_id=node_id,
|
||||||
|
node_execution_id=node_execution_id,
|
||||||
agent_id=binding.agent_id,
|
agent_id=binding.agent_id,
|
||||||
agent_config_version_id=binding.agent_config_version_id,
|
agent_config_version_id=binding.agent_config_version_id,
|
||||||
agent_config_version_kind=cast(
|
agent_config_version_kind=cast(
|
||||||
DifyExecutionContextAgentConfigVersionKind,
|
DifyExecutionContextAgentConfigVersionKind,
|
||||||
binding.agent_config_version_kind.value,
|
binding.agent_config_version_kind,
|
||||||
),
|
),
|
||||||
agent_mode="workflow_run",
|
agent_mode="workflow_run",
|
||||||
invoke_from="debugger",
|
invoke_from="debugger",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return _upload_download_response(tenant_id=tenant_id, file_mapping=uploaded.file.model_dump(mode="python"))
|
return _download_response(tenant_id=tenant_id, account_id=account_id, reference=downloaded.reference)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _resolve_binding(
|
def _resolve_binding(
|
||||||
@@ -339,7 +383,7 @@ class WorkflowAgentSandboxService:
|
|||||||
node_id: str,
|
node_id: str,
|
||||||
node_execution_id: str,
|
node_execution_id: str,
|
||||||
session: Session,
|
session: Session,
|
||||||
) -> AgentWorkspaceBinding:
|
) -> _ResolvedBinding:
|
||||||
execution = session.scalar(
|
execution = session.scalar(
|
||||||
select(WorkflowNodeExecutionModel).where(
|
select(WorkflowNodeExecutionModel).where(
|
||||||
WorkflowNodeExecutionModel.id == node_execution_id,
|
WorkflowNodeExecutionModel.id == node_execution_id,
|
||||||
@@ -379,28 +423,38 @@ class WorkflowAgentSandboxService:
|
|||||||
"this Workflow Agent node execution has no active Workspace Binding",
|
"this Workflow Agent node execution has no active Workspace Binding",
|
||||||
status_code=404,
|
status_code=404,
|
||||||
)
|
)
|
||||||
return binding
|
resolved = _binding_value(binding)
|
||||||
|
# Deliberately end the read transaction before the caller performs Dify Agent I/O.
|
||||||
|
session.rollback()
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
|
||||||
def _upload_download_response(*, tenant_id: str, file_mapping: dict[str, Any]) -> AgentSandboxUploadDownload:
|
def _binding_value(binding: AgentWorkspaceBinding) -> _ResolvedBinding:
|
||||||
controller = DatabaseFileAccessController()
|
return _ResolvedBinding(
|
||||||
runtime = DifyWorkflowFileRuntime(file_access_controller=controller)
|
backend_binding_ref=binding.backend_binding_ref,
|
||||||
|
agent_id=binding.agent_id,
|
||||||
|
agent_config_version_id=binding.agent_config_version_id,
|
||||||
|
agent_config_version_kind=binding.agent_config_version_kind.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _download_response(*, tenant_id: str, account_id: str, reference: str) -> AgentSandboxDownload:
|
||||||
try:
|
try:
|
||||||
file = file_factory.build_from_mapping(mapping=file_mapping, tenant_id=tenant_id, access_controller=controller)
|
result = FileRequestService().request_download(
|
||||||
url = runtime.resolve_file_url(file=file, for_external=True)
|
tenant_id=tenant_id,
|
||||||
|
user_id=account_id,
|
||||||
|
user_from="account",
|
||||||
|
invoke_from="debugger",
|
||||||
|
file_mapping={"transfer_method": "tool_file", "reference": reference},
|
||||||
|
)
|
||||||
|
url = bind_file_uri(result.download_uri, dify_config.FILES_URL)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise AgentSandboxInspectorError(
|
raise AgentSandboxInspectorError(
|
||||||
"workspace_upload_download_unavailable",
|
"binding_file_download_unavailable",
|
||||||
"uploaded Workspace file could not be converted to a download URL",
|
"Binding file could not be converted to a download URL",
|
||||||
status_code=502,
|
status_code=502,
|
||||||
) from exc
|
) from exc
|
||||||
if not url:
|
return AgentSandboxDownload(url=_with_as_attachment(url))
|
||||||
raise AgentSandboxInspectorError(
|
|
||||||
"workspace_upload_download_unavailable",
|
|
||||||
"uploaded Workspace file does not support download URL generation",
|
|
||||||
status_code=502,
|
|
||||||
)
|
|
||||||
return AgentSandboxUploadDownload(url=_with_as_attachment(url))
|
|
||||||
|
|
||||||
|
|
||||||
def _with_as_attachment(url: str) -> str:
|
def _with_as_attachment(url: str) -> str:
|
||||||
@@ -415,7 +469,7 @@ def _default_client_factory() -> Client:
|
|||||||
if not base_url:
|
if not base_url:
|
||||||
raise AgentSandboxInspectorError(
|
raise AgentSandboxInspectorError(
|
||||||
"inspector_unavailable",
|
"inspector_unavailable",
|
||||||
"the Workspace file inspector is not available (Agent backend not configured)",
|
"the Binding file inspector is not available (Agent backend not configured)",
|
||||||
status_code=503,
|
status_code=503,
|
||||||
)
|
)
|
||||||
return Client(base_url=base_url)
|
return Client(base_url=base_url)
|
||||||
@@ -423,8 +477,8 @@ def _default_client_factory() -> Client:
|
|||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AgentAppSandboxService",
|
"AgentAppSandboxService",
|
||||||
|
"AgentSandboxDownload",
|
||||||
"AgentSandboxInfo",
|
"AgentSandboxInfo",
|
||||||
"AgentSandboxInspectorError",
|
"AgentSandboxInspectorError",
|
||||||
"AgentSandboxUploadDownload",
|
|
||||||
"WorkflowAgentSandboxService",
|
"WorkflowAgentSandboxService",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,22 +2,23 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from inspect import unwrap
|
from inspect import unwrap
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError
|
from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError
|
||||||
from dify_agent.protocol import WorkspaceListResponse, WorkspaceReadResponse
|
from dify_agent.protocol import BindingFileListResponse, BindingFileReadResponse
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
|
|
||||||
from controllers.console import agent_app_sandbox as module
|
from controllers.console import agent_app_sandbox as module
|
||||||
from models.model import App, AppMode, IconType
|
from models.model import App, AppMode, IconType
|
||||||
from services.agent_app_sandbox_service import AgentSandboxInfo, AgentSandboxInspectorError, AgentSandboxUploadDownload
|
from services.agent_app_sandbox_service import AgentSandboxDownload, AgentSandboxInfo, AgentSandboxInspectorError
|
||||||
|
|
||||||
|
|
||||||
class _AgentAppService:
|
class _AgentAppService:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.calls: list[tuple[str, str, str, str, str, str, str, str]] = []
|
self.calls: list[tuple[str, str, str, str, str, str, str, str]] = []
|
||||||
|
|
||||||
|
def resolve_app_id(self, *, tenant_id: str, agent_id: str) -> str:
|
||||||
|
return "app-1"
|
||||||
|
|
||||||
def get_info(
|
def get_info(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -41,9 +42,9 @@ class _AgentAppService:
|
|||||||
caller_id: str,
|
caller_id: str,
|
||||||
account_id: str,
|
account_id: str,
|
||||||
path: str,
|
path: str,
|
||||||
) -> WorkspaceListResponse:
|
) -> BindingFileListResponse:
|
||||||
self.calls.append(("list", tenant_id, app_id, agent_id, caller_type, caller_id, account_id, path))
|
self.calls.append(("list", tenant_id, app_id, agent_id, caller_type, caller_id, account_id, path))
|
||||||
return WorkspaceListResponse(path=path, entries=[], truncated=False)
|
return BindingFileListResponse(path=path, entries=[], truncated=False)
|
||||||
|
|
||||||
def read_file(
|
def read_file(
|
||||||
self,
|
self,
|
||||||
@@ -55,11 +56,11 @@ class _AgentAppService:
|
|||||||
caller_id: str,
|
caller_id: str,
|
||||||
account_id: str,
|
account_id: str,
|
||||||
path: str,
|
path: str,
|
||||||
) -> WorkspaceReadResponse:
|
) -> BindingFileReadResponse:
|
||||||
self.calls.append(("read", tenant_id, app_id, agent_id, caller_type, caller_id, account_id, path))
|
self.calls.append(("read", tenant_id, app_id, agent_id, caller_type, caller_id, account_id, path))
|
||||||
return WorkspaceReadResponse(path=path, size=5, truncated=False, binary=False, text="hello")
|
return BindingFileReadResponse(path=path, size=5, truncated=False, binary=False, text="hello")
|
||||||
|
|
||||||
def upload_file(
|
def download_file(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
@@ -69,14 +70,17 @@ class _AgentAppService:
|
|||||||
caller_id: str,
|
caller_id: str,
|
||||||
account_id: str,
|
account_id: str,
|
||||||
path: str,
|
path: str,
|
||||||
) -> AgentSandboxUploadDownload:
|
) -> AgentSandboxDownload:
|
||||||
self.calls.append(("upload", tenant_id, app_id, agent_id, caller_type, caller_id, account_id, path))
|
self.calls.append(("download", tenant_id, app_id, agent_id, caller_type, caller_id, account_id, path))
|
||||||
return AgentSandboxUploadDownload(url="https://files.example/report.txt")
|
return AgentSandboxDownload(url="https://files.example/report.txt")
|
||||||
|
|
||||||
|
|
||||||
class _WorkflowService:
|
class _WorkflowService:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.calls: list[tuple[str, str, str, str, str, str]] = []
|
self.calls: list[tuple[str, ...]] = []
|
||||||
|
|
||||||
|
def resolve_app_id(self, *, tenant_id: str, app_id: str) -> str:
|
||||||
|
return app_id
|
||||||
|
|
||||||
def list_files(
|
def list_files(
|
||||||
self,
|
self,
|
||||||
@@ -88,9 +92,9 @@ class _WorkflowService:
|
|||||||
node_execution_id: str,
|
node_execution_id: str,
|
||||||
path: str,
|
path: str,
|
||||||
session,
|
session,
|
||||||
) -> WorkspaceListResponse:
|
) -> BindingFileListResponse:
|
||||||
self.calls.append(("list", tenant_id, app_id, workflow_run_id, node_id, path))
|
self.calls.append(("list", tenant_id, app_id, workflow_run_id, node_id, path))
|
||||||
return WorkspaceListResponse(path=path, entries=[], truncated=False)
|
return BindingFileListResponse(path=path, entries=[], truncated=False)
|
||||||
|
|
||||||
def read_file(
|
def read_file(
|
||||||
self,
|
self,
|
||||||
@@ -102,11 +106,11 @@ class _WorkflowService:
|
|||||||
node_execution_id: str,
|
node_execution_id: str,
|
||||||
path: str,
|
path: str,
|
||||||
session,
|
session,
|
||||||
) -> WorkspaceReadResponse:
|
) -> BindingFileReadResponse:
|
||||||
self.calls.append(("read", tenant_id, app_id, workflow_run_id, node_id, path))
|
self.calls.append(("read", tenant_id, app_id, workflow_run_id, node_id, path))
|
||||||
return WorkspaceReadResponse(path=path, size=5, truncated=False, binary=False, text="hello")
|
return BindingFileReadResponse(path=path, size=5, truncated=False, binary=False, text="hello")
|
||||||
|
|
||||||
def upload_file(
|
def download_file(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
@@ -114,11 +118,11 @@ class _WorkflowService:
|
|||||||
workflow_run_id: str,
|
workflow_run_id: str,
|
||||||
node_id: str,
|
node_id: str,
|
||||||
node_execution_id: str,
|
node_execution_id: str,
|
||||||
|
account_id: str,
|
||||||
path: str,
|
path: str,
|
||||||
session,
|
) -> AgentSandboxDownload:
|
||||||
) -> AgentSandboxUploadDownload:
|
self.calls.append(("download", tenant_id, app_id, workflow_run_id, node_id, account_id, path))
|
||||||
self.calls.append(("upload", tenant_id, app_id, workflow_run_id, node_id, path))
|
return AgentSandboxDownload(url="https://files.example/download.txt")
|
||||||
return AgentSandboxUploadDownload(url="https://files.example/upload.txt")
|
|
||||||
|
|
||||||
|
|
||||||
def _app_model(app_id: str = "app-1") -> App:
|
def _app_model(app_id: str = "app-1") -> App:
|
||||||
@@ -160,46 +164,40 @@ def test_handle_maps_sandbox_and_agent_backend_errors() -> None:
|
|||||||
module._handle(RuntimeError("boom"))
|
module._handle(RuntimeError("boom"))
|
||||||
|
|
||||||
|
|
||||||
def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPatch, unbound_session: Session) -> None:
|
def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
service = _AgentAppService()
|
service = _AgentAppService()
|
||||||
session = unbound_session
|
|
||||||
account = SimpleNamespace(id="account-1")
|
account = SimpleNamespace(id="account-1")
|
||||||
resolver = MagicMock(return_value=_app_model())
|
|
||||||
monkeypatch.setattr(module, "AgentAppSandboxService", lambda: service)
|
monkeypatch.setattr(module, "AgentAppSandboxService", lambda: service)
|
||||||
monkeypatch.setattr(module, "resolve_agent_runtime_app_model", resolver)
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
module,
|
module,
|
||||||
"query_params_from_request",
|
"query_params_from_request",
|
||||||
lambda model: SimpleNamespace(caller_type="build_draft", caller_id="build-1", path="sub/report.txt"),
|
lambda model: SimpleNamespace(caller_type="build_draft", caller_id="build-1", path="sub/report.txt"),
|
||||||
)
|
)
|
||||||
|
info = unwrap(module.AgentAppSandboxInfoResource.get)(object(), account, "tenant-1", "agent-1")
|
||||||
info = unwrap(module.AgentAppSandboxInfoResource.get)(object(), session, account, "tenant-1", "agent-1")
|
listing = unwrap(module.AgentAppSandboxListResource.get)(object(), account, "tenant-1", "agent-1")
|
||||||
listing = unwrap(module.AgentAppSandboxListResource.get)(object(), session, account, "tenant-1", "agent-1")
|
preview = unwrap(module.AgentAppSandboxReadResource.get)(object(), account, "tenant-1", "agent-1")
|
||||||
preview = unwrap(module.AgentAppSandboxReadResource.get)(object(), session, account, "tenant-1", "agent-1")
|
req_data = module.AgentSandboxDownloadPayload.model_validate(
|
||||||
req_data = module.AgentSandboxUploadPayload.model_validate(
|
|
||||||
{"caller_type": "build_draft", "caller_id": "build-1", "path": "report.txt"}
|
{"caller_type": "build_draft", "caller_id": "build-1", "path": "report.txt"}
|
||||||
)
|
)
|
||||||
upload = unwrap(module.AgentAppSandboxUploadResource.post)(
|
download = unwrap(module.AgentAppSandboxDownloadResource.post)(object(), req_data, account, "tenant-1", "agent-1")
|
||||||
object(), req_data, session, account, "tenant-1", "agent-1"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert info == {"workspace_cwd": "."}
|
assert info == {"workspace_cwd": "."}
|
||||||
assert listing["path"] == "sub/report.txt"
|
assert listing["path"] == "sub/report.txt"
|
||||||
assert preview["text"] == "hello"
|
assert preview["text"] == "hello"
|
||||||
assert upload == {"url": "https://files.example/report.txt"}
|
assert download == {"url": "https://files.example/report.txt"}
|
||||||
assert service.calls == [
|
assert service.calls == [
|
||||||
("info", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", ""),
|
("info", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", ""),
|
||||||
("list", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", "sub/report.txt"),
|
("list", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", "sub/report.txt"),
|
||||||
("read", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", "sub/report.txt"),
|
("read", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", "sub/report.txt"),
|
||||||
("upload", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", "report.txt"),
|
("download", "tenant-1", "app-1", "agent-1", "build_draft", "build-1", "account-1", "report.txt"),
|
||||||
]
|
]
|
||||||
assert all(call.kwargs["session"] is session for call in resolver.call_args_list)
|
|
||||||
|
|
||||||
|
|
||||||
def test_agent_app_sandbox_resource_returns_normalized_errors(
|
def test_agent_app_sandbox_resource_returns_normalized_errors(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch: pytest.MonkeyPatch, unbound_session: Session
|
|
||||||
) -> None:
|
|
||||||
class FailingService:
|
class FailingService:
|
||||||
|
def resolve_app_id(self, **kwargs):
|
||||||
|
return "app-1"
|
||||||
|
|
||||||
def get_info(self, **kwargs):
|
def get_info(self, **kwargs):
|
||||||
raise AgentSandboxInspectorError("no_active_binding", "no active binding", status_code=404)
|
raise AgentSandboxInspectorError("no_active_binding", "no active binding", status_code=404)
|
||||||
|
|
||||||
@@ -207,20 +205,18 @@ def test_agent_app_sandbox_resource_returns_normalized_errors(
|
|||||||
raise AgentSandboxInspectorError("no_active_binding", "no active binding", status_code=404)
|
raise AgentSandboxInspectorError("no_active_binding", "no active binding", status_code=404)
|
||||||
|
|
||||||
monkeypatch.setattr(module, "AgentAppSandboxService", FailingService)
|
monkeypatch.setattr(module, "AgentAppSandboxService", FailingService)
|
||||||
session = unbound_session
|
|
||||||
account = SimpleNamespace(id="account-1")
|
account = SimpleNamespace(id="account-1")
|
||||||
monkeypatch.setattr(module, "resolve_agent_runtime_app_model", MagicMock(return_value=_app_model()))
|
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
module,
|
module,
|
||||||
"query_params_from_request",
|
"query_params_from_request",
|
||||||
lambda model: SimpleNamespace(caller_type="conversation", caller_id="conv-1", path="."),
|
lambda model: SimpleNamespace(caller_type="conversation", caller_id="conv-1", path="."),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert unwrap(module.AgentAppSandboxInfoResource.get)(object(), session, account, "tenant-1", "agent-1") == (
|
assert unwrap(module.AgentAppSandboxInfoResource.get)(object(), account, "tenant-1", "agent-1") == (
|
||||||
{"code": "no_active_binding", "message": "no active binding"},
|
{"code": "no_active_binding", "message": "no active binding"},
|
||||||
404,
|
404,
|
||||||
)
|
)
|
||||||
assert unwrap(module.AgentAppSandboxListResource.get)(object(), session, account, "tenant-1", "agent-1") == (
|
assert unwrap(module.AgentAppSandboxListResource.get)(object(), account, "tenant-1", "agent-1") == (
|
||||||
{"code": "no_active_binding", "message": "no active binding"},
|
{"code": "no_active_binding", "message": "no active binding"},
|
||||||
404,
|
404,
|
||||||
)
|
)
|
||||||
@@ -242,18 +238,19 @@ def test_workflow_agent_sandbox_resources_proxy_service(monkeypatch: pytest.Monk
|
|||||||
preview = unwrap(module.WorkflowAgentSandboxReadResource.get)(
|
preview = unwrap(module.WorkflowAgentSandboxReadResource.get)(
|
||||||
object(), "tenant-1", app_model, "run-1", "agent-node"
|
object(), "tenant-1", app_model, "run-1", "agent-node"
|
||||||
)
|
)
|
||||||
req_data = module.WorkflowAgentSandboxUploadPayload.model_validate(
|
req_data = module.WorkflowAgentSandboxDownloadPayload.model_validate(
|
||||||
{"node_execution_id": "execution-1", "path": "upload.txt"}
|
{"node_execution_id": "execution-1", "path": "download.txt"}
|
||||||
)
|
)
|
||||||
upload = unwrap(module.WorkflowAgentSandboxUploadResource.post)(
|
account = SimpleNamespace(id="account-1")
|
||||||
object(), req_data, "tenant-1", app_model, "run-1", "agent-node"
|
download = unwrap(module.WorkflowAgentSandboxDownloadResource.post)(
|
||||||
|
object(), req_data, "tenant-1", account, "app-1", "run-1", "agent-node"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert listing["path"] == "out.txt"
|
assert listing["path"] == "out.txt"
|
||||||
assert preview["text"] == "hello"
|
assert preview["text"] == "hello"
|
||||||
assert upload == {"url": "https://files.example/upload.txt"}
|
assert download == {"url": "https://files.example/download.txt"}
|
||||||
assert service.calls == [
|
assert service.calls == [
|
||||||
("list", "tenant-1", "app-1", "run-1", "agent-node", "out.txt"),
|
("list", "tenant-1", "app-1", "run-1", "agent-node", "out.txt"),
|
||||||
("read", "tenant-1", "app-1", "run-1", "agent-node", "out.txt"),
|
("read", "tenant-1", "app-1", "run-1", "agent-node", "out.txt"),
|
||||||
("upload", "tenant-1", "app-1", "run-1", "agent-node", "upload.txt"),
|
("download", "tenant-1", "app-1", "run-1", "agent-node", "account-1", "download.txt"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import io
|
import io
|
||||||
import types
|
import types
|
||||||
|
from contextlib import contextmanager
|
||||||
from inspect import unwrap
|
from inspect import unwrap
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
from werkzeug.exceptions import Forbidden
|
from werkzeug.exceptions import Forbidden
|
||||||
|
|
||||||
import controllers.files.upload as module
|
import controllers.files.upload as module
|
||||||
from core.workflow.file_reference import build_file_reference
|
from core.workflow.file_reference import build_file_reference
|
||||||
|
from models import Account, TenantAccountJoin
|
||||||
|
from models.account import AccountStatus
|
||||||
|
|
||||||
|
|
||||||
def fake_request(args: dict, file=None):
|
def fake_request(args: dict, file=None):
|
||||||
@@ -17,6 +21,22 @@ def fake_request(args: dict, file=None):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_account_memberships(session: Session) -> None:
|
||||||
|
account = Account(name="Tenant member", email="member@example.com", status=AccountStatus.ACTIVE)
|
||||||
|
account.id = "account-1"
|
||||||
|
decoy = Account(name="Other tenant member", email="decoy@example.com", status=AccountStatus.ACTIVE)
|
||||||
|
decoy.id = "account-outside-tenant"
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
account,
|
||||||
|
decoy,
|
||||||
|
TenantAccountJoin(tenant_id="tenant-1", account_id=account.id),
|
||||||
|
TenantAccountJoin(tenant_id="tenant-other", account_id=decoy.id),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
class DummyUser:
|
class DummyUser:
|
||||||
def __init__(self, user_id="user-1"):
|
def __init__(self, user_id="user-1"):
|
||||||
self.id = user_id
|
self.id = user_id
|
||||||
@@ -33,6 +53,16 @@ class DummyFile:
|
|||||||
return self.stream.read()
|
return self.stream.read()
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingStream(io.BytesIO):
|
||||||
|
def __init__(self, content: bytes, events: list[str]):
|
||||||
|
super().__init__(content)
|
||||||
|
self.events = events
|
||||||
|
|
||||||
|
def read(self, *args, **kwargs):
|
||||||
|
self.events.append("file-read")
|
||||||
|
return super().read(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
class DummyToolFile:
|
class DummyToolFile:
|
||||||
def __init__(self, name="test.txt", mimetype="text/plain"):
|
def __init__(self, name="test.txt", mimetype="text/plain"):
|
||||||
self.id = "file-id"
|
self.id = "file-id"
|
||||||
@@ -94,6 +124,138 @@ class TestPluginUploadFileApi:
|
|||||||
assert tool_file_manager_instance.create_file_by_raw.call_args.kwargs["conversation_id"] == "conversation-1"
|
assert tool_file_manager_instance.create_file_by_raw.call_args.kwargs["conversation_id"] == "conversation-1"
|
||||||
mock_tool_file_manager.sign_file.assert_called_once_with(tool_file_id="file-id", extension=".docx")
|
mock_tool_file_manager.sign_file.assert_called_once_with(tool_file_id="file-id", extension=".docx")
|
||||||
|
|
||||||
|
@patch.object(module, "get_user")
|
||||||
|
@patch.object(module, "ToolFileManager")
|
||||||
|
@pytest.mark.parametrize("sqlite_session", [(Account, TenantAccountJoin)], indirect=True)
|
||||||
|
def test_account_upload_preserves_signed_account_owner(
|
||||||
|
self,
|
||||||
|
mock_tool_file_manager,
|
||||||
|
mock_get_user,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
sqlite_session: Session,
|
||||||
|
):
|
||||||
|
_persist_account_memberships(sqlite_session)
|
||||||
|
events: list[str] = []
|
||||||
|
dummy_file = DummyFile(filename="report.pdf", mimetype="application/pdf", content=b"account-owned")
|
||||||
|
dummy_file.stream = RecordingStream(b"account-owned", events)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def membership_session():
|
||||||
|
events.append("membership-session-enter")
|
||||||
|
try:
|
||||||
|
yield sqlite_session
|
||||||
|
finally:
|
||||||
|
events.append("membership-session-exit")
|
||||||
|
|
||||||
|
monkeypatch.setattr(module.session_factory, "create_session", membership_session)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
module,
|
||||||
|
"request",
|
||||||
|
fake_request(
|
||||||
|
{
|
||||||
|
"timestamp": "123",
|
||||||
|
"nonce": "abc",
|
||||||
|
"sign": "sig",
|
||||||
|
"tenant_id": "tenant-1",
|
||||||
|
"user_id": "account-1",
|
||||||
|
"user_from": "account",
|
||||||
|
},
|
||||||
|
file=dummy_file,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
tool_file_manager = mock_tool_file_manager.return_value
|
||||||
|
tool_file_manager.create_file_by_raw.side_effect = lambda **_kwargs: (
|
||||||
|
events.append("storage-create-file") or DummyToolFile(name="report.pdf", mimetype="application/pdf")
|
||||||
|
)
|
||||||
|
mock_tool_file_manager.sign_file.return_value = "signed-url"
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
module,
|
||||||
|
"verify_plugin_file_signature",
|
||||||
|
side_effect=lambda **_kwargs: events.append("signature-verify") or True,
|
||||||
|
) as verify_signature:
|
||||||
|
api = module.PluginUploadFileApi()
|
||||||
|
result, status_code = unwrap(api.post)(api)
|
||||||
|
|
||||||
|
assert status_code == 201
|
||||||
|
assert result["reference"] == build_file_reference(record_id="file-id")
|
||||||
|
assert events == [
|
||||||
|
"membership-session-enter",
|
||||||
|
"membership-session-exit",
|
||||||
|
"signature-verify",
|
||||||
|
"file-read",
|
||||||
|
"storage-create-file",
|
||||||
|
]
|
||||||
|
mock_get_user.assert_not_called()
|
||||||
|
verify_signature.assert_called_once_with(
|
||||||
|
filename="report.pdf",
|
||||||
|
mimetype="application/pdf",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="account-1",
|
||||||
|
conversation_id=None,
|
||||||
|
user_from="account",
|
||||||
|
timestamp="123",
|
||||||
|
nonce="abc",
|
||||||
|
sign="sig",
|
||||||
|
)
|
||||||
|
tool_file_manager.create_file_by_raw.assert_called_once_with(
|
||||||
|
user_id="account-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
file_binary=b"account-owned",
|
||||||
|
mimetype="application/pdf",
|
||||||
|
filename="report.pdf",
|
||||||
|
conversation_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@patch.object(module, "verify_plugin_file_signature")
|
||||||
|
@patch.object(module, "get_user")
|
||||||
|
@patch.object(module, "ToolFileManager")
|
||||||
|
@pytest.mark.parametrize("sqlite_session", [(Account, TenantAccountJoin)], indirect=True)
|
||||||
|
def test_account_upload_rejects_owner_outside_tenant(
|
||||||
|
self,
|
||||||
|
mock_tool_file_manager,
|
||||||
|
mock_get_user,
|
||||||
|
mock_verify_signature,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
sqlite_session: Session,
|
||||||
|
):
|
||||||
|
_persist_account_memberships(sqlite_session)
|
||||||
|
events: list[str] = []
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def membership_session():
|
||||||
|
events.append("membership-session-enter")
|
||||||
|
try:
|
||||||
|
yield sqlite_session
|
||||||
|
finally:
|
||||||
|
events.append("membership-session-exit")
|
||||||
|
|
||||||
|
monkeypatch.setattr(module.session_factory, "create_session", membership_session)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
module,
|
||||||
|
"request",
|
||||||
|
fake_request(
|
||||||
|
{
|
||||||
|
"timestamp": "123",
|
||||||
|
"nonce": "abc",
|
||||||
|
"sign": "sig",
|
||||||
|
"tenant_id": "tenant-1",
|
||||||
|
"user_id": "account-outside-tenant",
|
||||||
|
"user_from": "account",
|
||||||
|
},
|
||||||
|
file=DummyFile(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
api = module.PluginUploadFileApi()
|
||||||
|
with pytest.raises(Forbidden):
|
||||||
|
unwrap(api.post)(api)
|
||||||
|
|
||||||
|
assert events == ["membership-session-enter", "membership-session-exit"]
|
||||||
|
mock_get_user.assert_not_called()
|
||||||
|
mock_verify_signature.assert_not_called()
|
||||||
|
mock_tool_file_manager.assert_not_called()
|
||||||
|
|
||||||
def test_missing_file(self):
|
def test_missing_file(self):
|
||||||
module.request = fake_request(
|
module.request = fake_request(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ from unittest.mock import MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
from controllers.inner_api.agent.files import AgentFileDownloadRequestApi, AgentFileUploadRequestApi
|
from controllers.inner_api.agent.files import (
|
||||||
|
AgentFileDownloadRequestApi,
|
||||||
|
AgentFileRequestHttpError,
|
||||||
|
AgentFileUploadRequestApi,
|
||||||
|
)
|
||||||
from core.workflow.file_reference import build_file_reference
|
from core.workflow.file_reference import build_file_reference
|
||||||
from services.file_request_service import DownloadFileRequestResult
|
from services.file_request_service import DownloadFileRequestResult
|
||||||
|
|
||||||
@@ -46,9 +50,74 @@ def test_upload_request_returns_origin_free_uri(app: Flask) -> None:
|
|||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
user_id="canonical-end-user-1",
|
user_id="canonical-end-user-1",
|
||||||
conversation_id="conversation-1",
|
conversation_id="conversation-1",
|
||||||
|
user_from=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_request_preserves_tenant_scoped_account_owner(app: Flask) -> None:
|
||||||
|
payload = {
|
||||||
|
"tenant_id": "tenant-1",
|
||||||
|
"user_id": "account-1",
|
||||||
|
"user_from": "account",
|
||||||
|
"filename": "report.pdf",
|
||||||
|
"mimetype": "application/pdf",
|
||||||
|
"conversation_id": "conversation-1",
|
||||||
|
}
|
||||||
|
tenant = SimpleNamespace(id="tenant-1")
|
||||||
|
session = MagicMock()
|
||||||
|
with app.test_request_context("/", method="POST", json=payload):
|
||||||
|
with (
|
||||||
|
patch(f"{MODULE}.TenantService") as tenant_service,
|
||||||
|
patch(f"{MODULE}.get_user") as get_user,
|
||||||
|
patch(f"{MODULE}.get_signed_file_uri_for_plugin", return_value="/files/upload/for-plugin?sign=1") as sign,
|
||||||
|
):
|
||||||
|
tenant_service.get_tenant_by_id.return_value = tenant
|
||||||
|
tenant_service.account_belongs_to_tenant.return_value = True
|
||||||
|
response = _raw(AgentFileUploadRequestApi.post)(AgentFileUploadRequestApi(), session)
|
||||||
|
|
||||||
|
assert response == {"upload_uri": "/files/upload/for-plugin?sign=1"}
|
||||||
|
get_user.assert_not_called()
|
||||||
|
tenant_service.account_belongs_to_tenant.assert_called_once_with("account-1", "tenant-1", session=session)
|
||||||
|
sign.assert_called_once_with(
|
||||||
|
filename="report.pdf",
|
||||||
|
mimetype="application/pdf",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="account-1",
|
||||||
|
conversation_id="conversation-1",
|
||||||
|
user_from="account",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_request_rejects_account_outside_tenant_without_signing(app: Flask) -> None:
|
||||||
|
payload = {
|
||||||
|
"tenant_id": "tenant-1",
|
||||||
|
"user_id": "account-outside-tenant",
|
||||||
|
"user_from": "account",
|
||||||
|
"filename": "report.pdf",
|
||||||
|
"mimetype": "application/pdf",
|
||||||
|
}
|
||||||
|
tenant = SimpleNamespace(id="tenant-1")
|
||||||
|
session = MagicMock()
|
||||||
|
with app.test_request_context("/", method="POST", json=payload):
|
||||||
|
with (
|
||||||
|
patch(f"{MODULE}.TenantService") as tenant_service,
|
||||||
|
patch(f"{MODULE}.get_user") as get_user,
|
||||||
|
patch(f"{MODULE}.get_signed_file_uri_for_plugin") as sign,
|
||||||
|
):
|
||||||
|
tenant_service.get_tenant_by_id.return_value = tenant
|
||||||
|
tenant_service.account_belongs_to_tenant.return_value = False
|
||||||
|
with pytest.raises(AgentFileRequestHttpError) as exc_info:
|
||||||
|
_raw(AgentFileUploadRequestApi.post)(AgentFileUploadRequestApi(), session)
|
||||||
|
|
||||||
|
assert exc_info.value.error_code == "user_not_found"
|
||||||
|
assert exc_info.value.code == 404
|
||||||
|
tenant_service.account_belongs_to_tenant.assert_called_once_with(
|
||||||
|
"account-outside-tenant", "tenant-1", session=session
|
||||||
|
)
|
||||||
|
get_user.assert_not_called()
|
||||||
|
sign.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_download_request_returns_origin_free_uri_for_sandbox(app: Flask) -> None:
|
def test_download_request_returns_origin_free_uri_for_sandbox(app: Flask) -> None:
|
||||||
reference = build_file_reference(record_id="tool-file-1")
|
reference = build_file_reference(record_id="tool-file-1")
|
||||||
payload = {
|
payload = {
|
||||||
|
|||||||
@@ -187,6 +187,36 @@ def test_get_signed_file_uri_for_plugin_and_verify_roundtrip(monkeypatch: pytest
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_plugin_upload_signature_binds_account_user_from(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
|
||||||
|
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x09" * 16)
|
||||||
|
monkeypatch.setattr("core.tools.signature.dify_config.SECRET_KEY", "unit-secret")
|
||||||
|
monkeypatch.setattr("core.tools.signature.dify_config.FILES_ACCESS_TIMEOUT", 60)
|
||||||
|
|
||||||
|
uri = get_signed_file_uri_for_plugin(
|
||||||
|
filename="report.pdf",
|
||||||
|
mimetype="application/pdf",
|
||||||
|
tenant_id="tenant-id",
|
||||||
|
user_id="account-id",
|
||||||
|
user_from="account",
|
||||||
|
)
|
||||||
|
query = parse_qs(urlparse(uri).query)
|
||||||
|
|
||||||
|
assert query["user_from"] == ["account"]
|
||||||
|
signed = {
|
||||||
|
"filename": "report.pdf",
|
||||||
|
"mimetype": "application/pdf",
|
||||||
|
"tenant_id": "tenant-id",
|
||||||
|
"user_id": "account-id",
|
||||||
|
"timestamp": query["timestamp"][0],
|
||||||
|
"nonce": query["nonce"][0],
|
||||||
|
"sign": query["sign"][0],
|
||||||
|
}
|
||||||
|
assert verify_plugin_file_signature(**signed, user_from="account") is True
|
||||||
|
assert verify_plugin_file_signature(**signed, user_from="end-user") is False
|
||||||
|
assert verify_plugin_file_signature(**signed) is False
|
||||||
|
|
||||||
|
|
||||||
def test_verify_plugin_file_signature_rejects_invalid_signatures(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_verify_plugin_file_signature_rejects_invalid_signatures(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
|
monkeypatch.setattr("core.tools.signature.time.time", lambda: 1700000000)
|
||||||
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x07" * 16)
|
monkeypatch.setattr("core.tools.signature.os.urandom", lambda _: b"\x07" * 16)
|
||||||
|
|||||||
@@ -199,9 +199,13 @@ def test_load_or_create_persists_binding_on_node_execution(monkeypatch, home_sna
|
|||||||
session=session,
|
session=session,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert resolved.id == "binding-1"
|
assert resolved.backend_binding_ref == "backend-binding-1"
|
||||||
|
assert resolved.agent_id == "agent-1"
|
||||||
|
assert resolved.agent_config_version_id == "config-1"
|
||||||
|
assert resolved.agent_config_version_kind == "snapshot"
|
||||||
owner_scope = get_active.call_args.kwargs["expected_owner_scope"]
|
owner_scope = get_active.call_args.kwargs["expected_owner_scope"]
|
||||||
assert owner_scope.owner_scope_key == "node-1:workflow-binding-1"
|
assert owner_scope.owner_scope_key == "node-1:workflow-binding-1"
|
||||||
|
session.rollback.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
def test_load_existing_pointer_rejects_missing_workflow_identity(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_load_existing_pointer_rejects_missing_workflow_identity(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
|||||||
@@ -1,24 +1,39 @@
|
|||||||
from contextlib import nullcontext
|
import json
|
||||||
|
from contextlib import contextmanager, nullcontext
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
from typing import cast
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from dify_agent.protocol import WorkspaceListResponse, WorkspaceReadResponse
|
from dify_agent.client import Client
|
||||||
|
from dify_agent.protocol import BindingFileDownloadResponse, BindingFileListResponse, BindingFileReadResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from graphon.enums import WorkflowNodeExecutionStatus
|
||||||
from models.agent import (
|
from models.agent import (
|
||||||
|
Agent,
|
||||||
|
AgentConfigDraft,
|
||||||
|
AgentConfigDraftType,
|
||||||
AgentConfigVersionKind,
|
AgentConfigVersionKind,
|
||||||
|
AgentKind,
|
||||||
|
AgentScope,
|
||||||
|
AgentSource,
|
||||||
|
AgentStatus,
|
||||||
AgentWorkingResourceStatus,
|
AgentWorkingResourceStatus,
|
||||||
AgentWorkspace,
|
AgentWorkspace,
|
||||||
AgentWorkspaceBinding,
|
AgentWorkspaceBinding,
|
||||||
AgentWorkspaceOwnerType,
|
AgentWorkspaceOwnerType,
|
||||||
)
|
)
|
||||||
from models.enums import ConversationFromSource
|
from models.agent_config_entities import AgentSoulConfig
|
||||||
|
from models.enums import ConversationFromSource, CreatorUserRole
|
||||||
from models.model import App, AppMode, Conversation, IconType
|
from models.model import App, AppMode, Conversation, IconType
|
||||||
|
from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom
|
||||||
|
from services import agent_app_sandbox_service as sandbox_module
|
||||||
from services.agent.workspace_service import AgentWorkspaceService
|
from services.agent.workspace_service import AgentWorkspaceService
|
||||||
from services.agent_app_sandbox_service import (
|
from services.agent_app_sandbox_service import (
|
||||||
AgentAppSandboxService,
|
AgentAppSandboxService,
|
||||||
|
AgentSandboxDownload,
|
||||||
AgentSandboxInspectorError,
|
AgentSandboxInspectorError,
|
||||||
WorkflowAgentSandboxService,
|
WorkflowAgentSandboxService,
|
||||||
)
|
)
|
||||||
@@ -104,6 +119,79 @@ def _use_session(monkeypatch: pytest.MonkeyPatch, session: Session) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_app(session: Session, *, app_id: str, tenant_id: str) -> None:
|
||||||
|
session.add(
|
||||||
|
App(
|
||||||
|
id=app_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
name=f"App {app_id}",
|
||||||
|
description="",
|
||||||
|
mode=AppMode.AGENT,
|
||||||
|
icon_type=IconType.EMOJI,
|
||||||
|
icon="robot",
|
||||||
|
icon_background="#FFFFFF",
|
||||||
|
enable_site=False,
|
||||||
|
enable_api=False,
|
||||||
|
max_active_requests=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_binding(
|
||||||
|
session: Session,
|
||||||
|
*,
|
||||||
|
binding_id: str,
|
||||||
|
workspace_id: str,
|
||||||
|
tenant_id: str = "tenant-1",
|
||||||
|
app_id: str = "app-1",
|
||||||
|
agent_id: str = "agent-1",
|
||||||
|
owner_type: AgentWorkspaceOwnerType,
|
||||||
|
owner_id: str,
|
||||||
|
owner_scope_key: str = "root",
|
||||||
|
status: AgentWorkingResourceStatus = AgentWorkingResourceStatus.ACTIVE,
|
||||||
|
) -> AgentWorkspaceBinding:
|
||||||
|
active_guard = 1 if status is AgentWorkingResourceStatus.ACTIVE else None
|
||||||
|
workspace = AgentWorkspace(
|
||||||
|
id=workspace_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
app_id=app_id,
|
||||||
|
owner_type=owner_type,
|
||||||
|
owner_id=owner_id,
|
||||||
|
owner_scope_key=owner_scope_key,
|
||||||
|
backend_workspace_ref=f"{workspace_id}-ref",
|
||||||
|
status=status,
|
||||||
|
active_guard=active_guard,
|
||||||
|
)
|
||||||
|
binding = AgentWorkspaceBinding(
|
||||||
|
id=binding_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
app_id=app_id,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
agent_id=agent_id,
|
||||||
|
base_home_snapshot_id=None,
|
||||||
|
agent_config_version_id=f"{binding_id}-config",
|
||||||
|
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
|
||||||
|
backend_binding_ref=f"{binding_id}-ref",
|
||||||
|
status=status,
|
||||||
|
)
|
||||||
|
session.add_all([workspace, binding])
|
||||||
|
return binding
|
||||||
|
|
||||||
|
|
||||||
|
def _download_client() -> MagicMock:
|
||||||
|
client = MagicMock()
|
||||||
|
client.download_binding_file_sync.return_value = BindingFileDownloadResponse(reference="dify-file-ref:canonical")
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_download_response(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sandbox_module,
|
||||||
|
"_download_response",
|
||||||
|
lambda **_kwargs: AgentSandboxDownload(url="https://files.example/report.txt"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"sqlite_session",
|
"sqlite_session",
|
||||||
[(AgentWorkspace, AgentWorkspaceBinding, App, Conversation)],
|
[(AgentWorkspace, AgentWorkspaceBinding, App, Conversation)],
|
||||||
@@ -117,8 +205,8 @@ def test_agent_app_file_browsing_uses_conversation_pointer(
|
|||||||
sqlite_session.commit()
|
sqlite_session.commit()
|
||||||
_use_session(monkeypatch, sqlite_session)
|
_use_session(monkeypatch, sqlite_session)
|
||||||
client = MagicMock()
|
client = MagicMock()
|
||||||
response = WorkspaceListResponse(path=".", entries=[], truncated=False)
|
response = BindingFileListResponse(path=".", entries=[], truncated=False)
|
||||||
client.list_workspace_files_sync.return_value = response
|
client.list_binding_files_sync.return_value = response
|
||||||
|
|
||||||
result = AgentAppSandboxService(client_factory=lambda: nullcontext(client)).list_files(
|
result = AgentAppSandboxService(client_factory=lambda: nullcontext(client)).list_files(
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
@@ -131,7 +219,7 @@ def test_agent_app_file_browsing_uses_conversation_pointer(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result is response
|
assert result is response
|
||||||
client.list_workspace_files_sync.assert_called_once_with(expected.backend_binding_ref, ".")
|
client.list_binding_files_sync.assert_called_once_with(expected.backend_binding_ref, ".")
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -160,7 +248,443 @@ def test_agent_app_file_browsing_rejects_other_account(
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert exc_info.value.code == "no_active_binding"
|
assert exc_info.value.code == "no_active_binding"
|
||||||
client.list_workspace_files_sync.assert_not_called()
|
client.list_binding_files_sync.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"sqlite_session",
|
||||||
|
[(AgentWorkspace, AgentWorkspaceBinding, App, Conversation)],
|
||||||
|
indirect=True,
|
||||||
|
)
|
||||||
|
def test_agent_conversation_download_resolves_only_exact_active_owner_chain(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
sqlite_session: Session,
|
||||||
|
) -> None:
|
||||||
|
_add_app(sqlite_session, app_id="app-1", tenant_id="tenant-1")
|
||||||
|
_add_app(sqlite_session, app_id="app-other", tenant_id="tenant-other")
|
||||||
|
valid = _add_binding(
|
||||||
|
sqlite_session,
|
||||||
|
binding_id="binding-valid",
|
||||||
|
workspace_id="workspace-valid",
|
||||||
|
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
|
||||||
|
owner_id="conversation-valid",
|
||||||
|
)
|
||||||
|
wrong_owner = _add_binding(
|
||||||
|
sqlite_session,
|
||||||
|
binding_id="binding-wrong-owner",
|
||||||
|
workspace_id="workspace-wrong-owner",
|
||||||
|
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
|
||||||
|
owner_id="conversation-not-the-caller",
|
||||||
|
)
|
||||||
|
retired = _add_binding(
|
||||||
|
sqlite_session,
|
||||||
|
binding_id="binding-retired",
|
||||||
|
workspace_id="workspace-retired",
|
||||||
|
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
|
||||||
|
owner_id="conversation-retired",
|
||||||
|
status=AgentWorkingResourceStatus.RETIRED,
|
||||||
|
)
|
||||||
|
cross_tenant = _add_binding(
|
||||||
|
sqlite_session,
|
||||||
|
binding_id="binding-cross-tenant",
|
||||||
|
workspace_id="workspace-cross-tenant",
|
||||||
|
tenant_id="tenant-other",
|
||||||
|
app_id="app-other",
|
||||||
|
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
|
||||||
|
owner_id="conversation-cross-tenant",
|
||||||
|
)
|
||||||
|
conversations = [
|
||||||
|
Conversation(
|
||||||
|
id="conversation-valid",
|
||||||
|
app_id="app-1",
|
||||||
|
mode=AppMode.AGENT,
|
||||||
|
name="Valid",
|
||||||
|
from_source=ConversationFromSource.CONSOLE,
|
||||||
|
from_account_id="account-1",
|
||||||
|
is_deleted=False,
|
||||||
|
agent_workspace_binding_id=valid.id,
|
||||||
|
),
|
||||||
|
Conversation(
|
||||||
|
id="conversation-wrong-owner",
|
||||||
|
app_id="app-1",
|
||||||
|
mode=AppMode.AGENT,
|
||||||
|
name="Wrong owner",
|
||||||
|
from_source=ConversationFromSource.CONSOLE,
|
||||||
|
from_account_id="account-1",
|
||||||
|
is_deleted=False,
|
||||||
|
agent_workspace_binding_id=wrong_owner.id,
|
||||||
|
),
|
||||||
|
Conversation(
|
||||||
|
id="conversation-retired",
|
||||||
|
app_id="app-1",
|
||||||
|
mode=AppMode.AGENT,
|
||||||
|
name="Retired",
|
||||||
|
from_source=ConversationFromSource.CONSOLE,
|
||||||
|
from_account_id="account-1",
|
||||||
|
is_deleted=False,
|
||||||
|
agent_workspace_binding_id=retired.id,
|
||||||
|
),
|
||||||
|
Conversation(
|
||||||
|
id="conversation-cross-tenant",
|
||||||
|
app_id="app-other",
|
||||||
|
mode=AppMode.AGENT,
|
||||||
|
name="Cross tenant",
|
||||||
|
from_source=ConversationFromSource.CONSOLE,
|
||||||
|
from_account_id="account-other",
|
||||||
|
is_deleted=False,
|
||||||
|
agent_workspace_binding_id=cross_tenant.id,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
for conversation in conversations:
|
||||||
|
conversation._inputs = {}
|
||||||
|
sqlite_session.add_all(conversations)
|
||||||
|
sqlite_session.commit()
|
||||||
|
_use_session(monkeypatch, sqlite_session)
|
||||||
|
_stub_download_response(monkeypatch)
|
||||||
|
client = _download_client()
|
||||||
|
service = AgentAppSandboxService(client_factory=lambda: nullcontext(cast(Client, client)))
|
||||||
|
|
||||||
|
result = service.download_file(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
app_id="app-1",
|
||||||
|
agent_id="agent-1",
|
||||||
|
caller_type="conversation",
|
||||||
|
caller_id="conversation-valid",
|
||||||
|
account_id="account-1",
|
||||||
|
path="report.txt",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.url == "https://files.example/report.txt"
|
||||||
|
request = client.download_binding_file_sync.call_args.args[0]
|
||||||
|
assert request.backend_binding_ref == "binding-valid-ref"
|
||||||
|
client.download_binding_file_sync.reset_mock()
|
||||||
|
|
||||||
|
rejected_locators = [
|
||||||
|
{"account_id": "account-other"},
|
||||||
|
{"app_id": "app-other"},
|
||||||
|
{"caller_id": "conversation-wrong-owner"},
|
||||||
|
{"caller_id": "conversation-retired"},
|
||||||
|
{
|
||||||
|
"tenant_id": "tenant-1",
|
||||||
|
"app_id": "app-other",
|
||||||
|
"caller_id": "conversation-cross-tenant",
|
||||||
|
"account_id": "account-other",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
for override in rejected_locators:
|
||||||
|
locator = {
|
||||||
|
"tenant_id": "tenant-1",
|
||||||
|
"app_id": "app-1",
|
||||||
|
"agent_id": "agent-1",
|
||||||
|
"caller_type": "conversation",
|
||||||
|
"caller_id": "conversation-valid",
|
||||||
|
"account_id": "account-1",
|
||||||
|
"path": "report.txt",
|
||||||
|
}
|
||||||
|
locator.update(override)
|
||||||
|
with pytest.raises(AgentSandboxInspectorError, match="active Agent Workspace Binding"):
|
||||||
|
service.download_file(**locator) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
client.download_binding_file_sync.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"sqlite_session",
|
||||||
|
[(Agent, AgentConfigDraft, AgentWorkspace, AgentWorkspaceBinding)],
|
||||||
|
indirect=True,
|
||||||
|
)
|
||||||
|
def test_agent_build_draft_download_resolves_only_exact_active_owner_chain(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
sqlite_session: Session,
|
||||||
|
) -> None:
|
||||||
|
sqlite_session.add_all(
|
||||||
|
[
|
||||||
|
Agent(
|
||||||
|
id="agent-1",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
name="Agent",
|
||||||
|
description="",
|
||||||
|
agent_kind=AgentKind.DIFY_AGENT,
|
||||||
|
scope=AgentScope.ROSTER,
|
||||||
|
source=AgentSource.AGENT_APP,
|
||||||
|
app_id="app-1",
|
||||||
|
status=AgentStatus.ACTIVE,
|
||||||
|
),
|
||||||
|
Agent(
|
||||||
|
id="agent-cross-tenant",
|
||||||
|
tenant_id="tenant-other",
|
||||||
|
name="Other tenant Agent",
|
||||||
|
description="",
|
||||||
|
agent_kind=AgentKind.DIFY_AGENT,
|
||||||
|
scope=AgentScope.ROSTER,
|
||||||
|
source=AgentSource.AGENT_APP,
|
||||||
|
app_id="app-other",
|
||||||
|
status=AgentStatus.ACTIVE,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
valid = _add_binding(
|
||||||
|
sqlite_session,
|
||||||
|
binding_id="binding-build-valid",
|
||||||
|
workspace_id="workspace-build-valid",
|
||||||
|
owner_type=AgentWorkspaceOwnerType.BUILD_DRAFT,
|
||||||
|
owner_id="draft-valid",
|
||||||
|
)
|
||||||
|
wrong_owner = _add_binding(
|
||||||
|
sqlite_session,
|
||||||
|
binding_id="binding-build-wrong-owner",
|
||||||
|
workspace_id="workspace-build-wrong-owner",
|
||||||
|
owner_type=AgentWorkspaceOwnerType.BUILD_DRAFT,
|
||||||
|
owner_id="draft-not-the-caller",
|
||||||
|
)
|
||||||
|
retired = _add_binding(
|
||||||
|
sqlite_session,
|
||||||
|
binding_id="binding-build-retired",
|
||||||
|
workspace_id="workspace-build-retired",
|
||||||
|
owner_type=AgentWorkspaceOwnerType.BUILD_DRAFT,
|
||||||
|
owner_id="draft-retired",
|
||||||
|
status=AgentWorkingResourceStatus.RETIRED,
|
||||||
|
)
|
||||||
|
drafts = [
|
||||||
|
AgentConfigDraft(
|
||||||
|
id="draft-valid",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
agent_id="agent-1",
|
||||||
|
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||||
|
account_id="account-1",
|
||||||
|
draft_owner_key="account-1",
|
||||||
|
agent_workspace_binding_id=valid.id,
|
||||||
|
config_snapshot=AgentSoulConfig(),
|
||||||
|
),
|
||||||
|
AgentConfigDraft(
|
||||||
|
id="draft-wrong-owner",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
agent_id="agent-1",
|
||||||
|
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||||
|
account_id="account-2",
|
||||||
|
draft_owner_key="account-2",
|
||||||
|
agent_workspace_binding_id=wrong_owner.id,
|
||||||
|
config_snapshot=AgentSoulConfig(),
|
||||||
|
),
|
||||||
|
AgentConfigDraft(
|
||||||
|
id="draft-retired",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
agent_id="agent-1",
|
||||||
|
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||||
|
account_id="account-3",
|
||||||
|
draft_owner_key="account-3",
|
||||||
|
agent_workspace_binding_id=retired.id,
|
||||||
|
config_snapshot=AgentSoulConfig(),
|
||||||
|
),
|
||||||
|
AgentConfigDraft(
|
||||||
|
id="draft-cross-tenant",
|
||||||
|
tenant_id="tenant-other",
|
||||||
|
agent_id="agent-cross-tenant",
|
||||||
|
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||||
|
account_id="account-other",
|
||||||
|
draft_owner_key="account-other",
|
||||||
|
agent_workspace_binding_id=None,
|
||||||
|
config_snapshot=AgentSoulConfig(),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
sqlite_session.add_all(drafts)
|
||||||
|
sqlite_session.commit()
|
||||||
|
_use_session(monkeypatch, sqlite_session)
|
||||||
|
_stub_download_response(monkeypatch)
|
||||||
|
client = _download_client()
|
||||||
|
service = AgentAppSandboxService(client_factory=lambda: nullcontext(cast(Client, client)))
|
||||||
|
|
||||||
|
result = service.download_file(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
app_id="app-1",
|
||||||
|
agent_id="agent-1",
|
||||||
|
caller_type="build_draft",
|
||||||
|
caller_id="draft-valid",
|
||||||
|
account_id="account-1",
|
||||||
|
path="report.txt",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.url == "https://files.example/report.txt"
|
||||||
|
assert client.download_binding_file_sync.call_args.args[0].backend_binding_ref == "binding-build-valid-ref"
|
||||||
|
client.download_binding_file_sync.reset_mock()
|
||||||
|
|
||||||
|
rejected_locators = [
|
||||||
|
{"account_id": "account-other"},
|
||||||
|
{"app_id": "app-other"},
|
||||||
|
{"caller_id": "draft-wrong-owner", "account_id": "account-2"},
|
||||||
|
{"caller_id": "draft-retired", "account_id": "account-3"},
|
||||||
|
{
|
||||||
|
"tenant_id": "tenant-1",
|
||||||
|
"app_id": "app-other",
|
||||||
|
"agent_id": "agent-cross-tenant",
|
||||||
|
"caller_id": "draft-cross-tenant",
|
||||||
|
"account_id": "account-other",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
for override in rejected_locators:
|
||||||
|
locator = {
|
||||||
|
"tenant_id": "tenant-1",
|
||||||
|
"app_id": "app-1",
|
||||||
|
"agent_id": "agent-1",
|
||||||
|
"caller_type": "build_draft",
|
||||||
|
"caller_id": "draft-valid",
|
||||||
|
"account_id": "account-1",
|
||||||
|
"path": "report.txt",
|
||||||
|
}
|
||||||
|
locator.update(override)
|
||||||
|
with pytest.raises(AgentSandboxInspectorError, match="active Agent Workspace Binding"):
|
||||||
|
service.download_file(**locator) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
client.download_binding_file_sync.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def _workflow_execution(
|
||||||
|
*,
|
||||||
|
execution_id: str,
|
||||||
|
tenant_id: str = "tenant-1",
|
||||||
|
app_id: str = "app-1",
|
||||||
|
workflow_run_id: str = "run-1",
|
||||||
|
node_id: str = "node-1",
|
||||||
|
binding_id: str,
|
||||||
|
created_by: str = "historical-account",
|
||||||
|
) -> WorkflowNodeExecutionModel:
|
||||||
|
return WorkflowNodeExecutionModel(
|
||||||
|
id=execution_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
app_id=app_id,
|
||||||
|
workflow_id="workflow-1",
|
||||||
|
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
|
||||||
|
workflow_run_id=workflow_run_id,
|
||||||
|
index=1,
|
||||||
|
node_id=node_id,
|
||||||
|
node_type="agent",
|
||||||
|
title=node_id,
|
||||||
|
agent_workspace_binding_id=binding_id,
|
||||||
|
inputs=None,
|
||||||
|
process_data=json.dumps({"workflow_agent_binding_id": "workflow-binding-1"}),
|
||||||
|
outputs=None,
|
||||||
|
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
||||||
|
error=None,
|
||||||
|
created_by_role=CreatorUserRole.ACCOUNT,
|
||||||
|
created_by=created_by,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"sqlite_session",
|
||||||
|
[(WorkflowNodeExecutionModel, AgentWorkspace, AgentWorkspaceBinding)],
|
||||||
|
indirect=True,
|
||||||
|
)
|
||||||
|
def test_workflow_download_resolves_only_exact_active_owner_chain(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
sqlite_session: Session,
|
||||||
|
) -> None:
|
||||||
|
valid = _add_binding(
|
||||||
|
sqlite_session,
|
||||||
|
binding_id="binding-workflow-valid",
|
||||||
|
workspace_id="workspace-workflow-valid",
|
||||||
|
owner_type=AgentWorkspaceOwnerType.WORKFLOW_RUN,
|
||||||
|
owner_id="run-1",
|
||||||
|
owner_scope_key="node-1:workflow-binding-1",
|
||||||
|
)
|
||||||
|
wrong_owner = _add_binding(
|
||||||
|
sqlite_session,
|
||||||
|
binding_id="binding-workflow-wrong-owner",
|
||||||
|
workspace_id="workspace-workflow-wrong-owner",
|
||||||
|
owner_type=AgentWorkspaceOwnerType.WORKFLOW_RUN,
|
||||||
|
owner_id="run-not-the-caller",
|
||||||
|
owner_scope_key="node-1:workflow-binding-1",
|
||||||
|
)
|
||||||
|
retired = AgentWorkspaceBinding(
|
||||||
|
id="binding-workflow-retired",
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
app_id="app-1",
|
||||||
|
workspace_id=valid.workspace_id,
|
||||||
|
agent_id="agent-1",
|
||||||
|
base_home_snapshot_id=None,
|
||||||
|
agent_config_version_id="binding-workflow-retired-config",
|
||||||
|
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
|
||||||
|
backend_binding_ref="binding-workflow-retired-ref",
|
||||||
|
status=AgentWorkingResourceStatus.RETIRED,
|
||||||
|
)
|
||||||
|
sqlite_session.add(retired)
|
||||||
|
sqlite_session.add_all(
|
||||||
|
[
|
||||||
|
_workflow_execution(execution_id="execution-valid", binding_id=valid.id),
|
||||||
|
_workflow_execution(
|
||||||
|
execution_id="execution-cross-tenant",
|
||||||
|
tenant_id="tenant-other",
|
||||||
|
binding_id=valid.id,
|
||||||
|
),
|
||||||
|
_workflow_execution(execution_id="execution-wrong-app", app_id="app-other", binding_id=valid.id),
|
||||||
|
_workflow_execution(execution_id="execution-wrong-run", workflow_run_id="run-other", binding_id=valid.id),
|
||||||
|
_workflow_execution(execution_id="execution-wrong-node", node_id="node-other", binding_id=valid.id),
|
||||||
|
_workflow_execution(execution_id="execution-wrong-owner", binding_id=wrong_owner.id),
|
||||||
|
_workflow_execution(execution_id="execution-retired", binding_id=retired.id),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
sqlite_session.commit()
|
||||||
|
persisted_execution = sqlite_session.get(WorkflowNodeExecutionModel, "execution-valid")
|
||||||
|
assert persisted_execution is not None
|
||||||
|
assert persisted_execution.created_by == "historical-account"
|
||||||
|
_use_session(monkeypatch, sqlite_session)
|
||||||
|
client = _download_client()
|
||||||
|
request_download = MagicMock(
|
||||||
|
return_value=SimpleNamespace(download_uri="/files/tools/report.txt?timestamp=1&sign=2")
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sandbox_module,
|
||||||
|
"FileRequestService",
|
||||||
|
lambda: SimpleNamespace(request_download=request_download),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(sandbox_module.dify_config, "FILES_URL", "https://files.example")
|
||||||
|
service = WorkflowAgentSandboxService(client_factory=lambda: nullcontext(cast(Client, client)))
|
||||||
|
|
||||||
|
result = service.download_file(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
app_id="app-1",
|
||||||
|
workflow_run_id="run-1",
|
||||||
|
node_id="node-1",
|
||||||
|
node_execution_id="execution-valid",
|
||||||
|
account_id="authenticated-account",
|
||||||
|
path="report.txt",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.url == "https://files.example/files/tools/report.txt?timestamp=1&sign=2&as_attachment=true"
|
||||||
|
request = client.download_binding_file_sync.call_args.args[0]
|
||||||
|
assert request.backend_binding_ref == "binding-workflow-valid-ref"
|
||||||
|
assert request.execution_context.user_id == "authenticated-account"
|
||||||
|
assert request.execution_context.user_from == "account"
|
||||||
|
request_download.assert_called_once_with(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="authenticated-account",
|
||||||
|
user_from="account",
|
||||||
|
invoke_from="debugger",
|
||||||
|
file_mapping={"transfer_method": "tool_file", "reference": "dify-file-ref:canonical"},
|
||||||
|
)
|
||||||
|
client.download_binding_file_sync.reset_mock()
|
||||||
|
|
||||||
|
for node_execution_id in (
|
||||||
|
"execution-cross-tenant",
|
||||||
|
"execution-wrong-app",
|
||||||
|
"execution-wrong-run",
|
||||||
|
"execution-wrong-node",
|
||||||
|
"execution-wrong-owner",
|
||||||
|
"execution-retired",
|
||||||
|
):
|
||||||
|
with pytest.raises(AgentSandboxInspectorError, match="active Workspace Binding"):
|
||||||
|
service.download_file(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
app_id="app-1",
|
||||||
|
workflow_run_id="run-1",
|
||||||
|
node_id="node-1",
|
||||||
|
node_execution_id=node_execution_id,
|
||||||
|
account_id="authenticated-account",
|
||||||
|
path="report.txt",
|
||||||
|
)
|
||||||
|
|
||||||
|
client.download_binding_file_sync.assert_not_called()
|
||||||
|
assert request_download.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -185,12 +709,14 @@ def test_agent_app_file_browsing_uses_build_draft_caller(
|
|||||||
binding = SimpleNamespace(
|
binding = SimpleNamespace(
|
||||||
agent_id="agent-1",
|
agent_id="agent-1",
|
||||||
backend_binding_ref="binding-build-ref",
|
backend_binding_ref="binding-build-ref",
|
||||||
|
agent_config_version_id="config-1",
|
||||||
|
agent_config_version_kind=AgentConfigVersionKind.DRAFT,
|
||||||
)
|
)
|
||||||
get_binding = MagicMock(return_value=binding)
|
get_binding = MagicMock(return_value=binding)
|
||||||
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", get_binding)
|
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", get_binding)
|
||||||
client = MagicMock()
|
client = MagicMock()
|
||||||
response = WorkspaceListResponse(path=".", entries=[], truncated=False)
|
response = BindingFileListResponse(path=".", entries=[], truncated=False)
|
||||||
client.list_workspace_files_sync.return_value = response
|
client.list_binding_files_sync.return_value = response
|
||||||
|
|
||||||
result = AgentAppSandboxService(client_factory=lambda: nullcontext(client)).list_files(
|
result = AgentAppSandboxService(client_factory=lambda: nullcontext(client)).list_files(
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
@@ -207,7 +733,7 @@ def test_agent_app_file_browsing_uses_build_draft_caller(
|
|||||||
assert owner_scope.app_id == runtime_app_id
|
assert owner_scope.app_id == runtime_app_id
|
||||||
assert owner_scope.owner_type is AgentWorkspaceOwnerType.BUILD_DRAFT
|
assert owner_scope.owner_type is AgentWorkspaceOwnerType.BUILD_DRAFT
|
||||||
assert owner_scope.owner_id == "build-1"
|
assert owner_scope.owner_id == "build-1"
|
||||||
client.list_workspace_files_sync.assert_called_once_with("binding-build-ref", ".")
|
client.list_binding_files_sync.assert_called_once_with("binding-build-ref", ".")
|
||||||
|
|
||||||
|
|
||||||
def test_workflow_file_access_uses_node_execution_pointer(monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_workflow_file_access_uses_node_execution_pointer(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
@@ -217,12 +743,17 @@ def test_workflow_file_access_uses_node_execution_pointer(monkeypatch: pytest.Mo
|
|||||||
)
|
)
|
||||||
session = MagicMock()
|
session = MagicMock()
|
||||||
session.scalar.return_value = execution
|
session.scalar.return_value = execution
|
||||||
binding = SimpleNamespace(backend_binding_ref="binding-workflow-ref")
|
binding = SimpleNamespace(
|
||||||
|
agent_id="agent-1",
|
||||||
|
backend_binding_ref="binding-workflow-ref",
|
||||||
|
agent_config_version_id="config-1",
|
||||||
|
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
|
||||||
|
)
|
||||||
get_binding = MagicMock(return_value=binding)
|
get_binding = MagicMock(return_value=binding)
|
||||||
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", get_binding)
|
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", get_binding)
|
||||||
client = MagicMock()
|
client = MagicMock()
|
||||||
response = WorkspaceReadResponse(path="report.txt", size=2, truncated=False, binary=False, text="ok")
|
response = BindingFileReadResponse(path="report.txt", size=2, truncated=False, binary=False, text="ok")
|
||||||
client.read_workspace_file_sync.return_value = response
|
client.read_binding_file_sync.return_value = response
|
||||||
|
|
||||||
result = WorkflowAgentSandboxService(client_factory=lambda: nullcontext(client)).read_file(
|
result = WorkflowAgentSandboxService(client_factory=lambda: nullcontext(client)).read_file(
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
@@ -236,4 +767,238 @@ def test_workflow_file_access_uses_node_execution_pointer(monkeypatch: pytest.Mo
|
|||||||
|
|
||||||
assert result is response
|
assert result is response
|
||||||
assert get_binding.call_args.kwargs["binding_id"] == "binding-workflow"
|
assert get_binding.call_args.kwargs["binding_id"] == "binding-workflow"
|
||||||
client.read_workspace_file_sync.assert_called_once_with("binding-workflow-ref", "report.txt")
|
client.read_binding_file_sync.assert_called_once_with("binding-workflow-ref", "report.txt")
|
||||||
|
session.rollback.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_download_uses_authenticated_account_and_trusted_file_request(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
execution = SimpleNamespace(
|
||||||
|
agent_workspace_binding_id="binding-workflow",
|
||||||
|
process_data_dict={"workflow_agent_binding_id": "workflow-binding-1"},
|
||||||
|
)
|
||||||
|
session = MagicMock()
|
||||||
|
session.scalar.return_value = execution
|
||||||
|
binding = SimpleNamespace(
|
||||||
|
agent_id="agent-1",
|
||||||
|
backend_binding_ref="binding-workflow-ref",
|
||||||
|
agent_config_version_id="config-1",
|
||||||
|
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", MagicMock(return_value=binding))
|
||||||
|
events: list[str] = []
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def session_scope():
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
events.append("session-exit")
|
||||||
|
|
||||||
|
monkeypatch.setattr(sandbox_module.session_factory, "create_session", session_scope)
|
||||||
|
client = MagicMock()
|
||||||
|
client.download_binding_file_sync.side_effect = lambda _request: (
|
||||||
|
events.append("client-download") or BindingFileDownloadResponse(reference="dify-file-ref:canonical")
|
||||||
|
)
|
||||||
|
request_download = MagicMock(
|
||||||
|
side_effect=lambda **_kwargs: (
|
||||||
|
events.append("file-request") or SimpleNamespace(download_uri="/files/tools/report.txt?timestamp=1&sign=2")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"services.agent_app_sandbox_service.FileRequestService",
|
||||||
|
lambda: SimpleNamespace(request_download=request_download),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("services.agent_app_sandbox_service.dify_config.FILES_URL", "https://files.example")
|
||||||
|
|
||||||
|
result = WorkflowAgentSandboxService(client_factory=lambda: nullcontext(client)).download_file(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
app_id="app-1",
|
||||||
|
workflow_run_id="run-1",
|
||||||
|
node_id="node-1",
|
||||||
|
node_execution_id="execution-1",
|
||||||
|
account_id="account-1",
|
||||||
|
path="report.txt",
|
||||||
|
)
|
||||||
|
|
||||||
|
request = client.download_binding_file_sync.call_args.args[0]
|
||||||
|
assert request.execution_context.user_id == "account-1"
|
||||||
|
assert request.execution_context.user_from == "account"
|
||||||
|
assert request.execution_context.node_execution_id == "execution-1"
|
||||||
|
session.rollback.assert_called_once_with()
|
||||||
|
assert events == ["session-exit", "client-download", "file-request"]
|
||||||
|
request_download.assert_called_once_with(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="account-1",
|
||||||
|
user_from="account",
|
||||||
|
invoke_from="debugger",
|
||||||
|
file_mapping={"transfer_method": "tool_file", "reference": "dify-file-ref:canonical"},
|
||||||
|
)
|
||||||
|
assert result.url == "https://files.example/files/tools/report.txt?timestamp=1&sign=2&as_attachment=true"
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_app_download_uses_complete_account_context_after_session_exit(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
events: list[str] = []
|
||||||
|
session = MagicMock()
|
||||||
|
session.scalar.side_effect = [
|
||||||
|
SimpleNamespace(app_id="app-1", backing_app_id=None),
|
||||||
|
SimpleNamespace(agent_workspace_binding_id="binding-build"),
|
||||||
|
]
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def session_scope():
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
events.append("session-exit")
|
||||||
|
|
||||||
|
monkeypatch.setattr(sandbox_module.session_factory, "create_session", session_scope)
|
||||||
|
binding = SimpleNamespace(
|
||||||
|
agent_id="agent-1",
|
||||||
|
backend_binding_ref="binding-build-ref",
|
||||||
|
agent_config_version_id="config-1",
|
||||||
|
agent_config_version_kind=AgentConfigVersionKind.DRAFT,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", MagicMock(return_value=binding))
|
||||||
|
client = MagicMock()
|
||||||
|
client.download_binding_file_sync.side_effect = lambda _request: (
|
||||||
|
events.append("client-download") or BindingFileDownloadResponse(reference="dify-file-ref:canonical")
|
||||||
|
)
|
||||||
|
request_download = MagicMock(
|
||||||
|
side_effect=lambda **_kwargs: (
|
||||||
|
events.append("file-request") or SimpleNamespace(download_uri="/files/tools/report.txt?timestamp=1&sign=2")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sandbox_module,
|
||||||
|
"FileRequestService",
|
||||||
|
lambda: SimpleNamespace(request_download=request_download),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(sandbox_module.dify_config, "FILES_URL", "https://files.example")
|
||||||
|
|
||||||
|
result = AgentAppSandboxService(client_factory=lambda: nullcontext(client)).download_file(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
app_id="app-1",
|
||||||
|
agent_id="agent-1",
|
||||||
|
caller_type="build_draft",
|
||||||
|
caller_id="build-1",
|
||||||
|
account_id="account-1",
|
||||||
|
path="report.txt",
|
||||||
|
)
|
||||||
|
|
||||||
|
request = client.download_binding_file_sync.call_args.args[0]
|
||||||
|
assert request.backend_binding_ref == "binding-build-ref"
|
||||||
|
assert request.path == "report.txt"
|
||||||
|
assert request.execution_context.model_dump(exclude_none=True) == {
|
||||||
|
"tenant_id": "tenant-1",
|
||||||
|
"user_id": "account-1",
|
||||||
|
"user_from": "account",
|
||||||
|
"app_id": "app-1",
|
||||||
|
"agent_id": "agent-1",
|
||||||
|
"agent_config_version_id": "config-1",
|
||||||
|
"agent_config_version_kind": "draft",
|
||||||
|
"agent_mode": "agent_app",
|
||||||
|
"invoke_from": "debugger",
|
||||||
|
}
|
||||||
|
assert events == ["session-exit", "client-download", "file-request"]
|
||||||
|
request_download.assert_called_once_with(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="account-1",
|
||||||
|
user_from="account",
|
||||||
|
invoke_from="debugger",
|
||||||
|
file_mapping={"transfer_method": "tool_file", "reference": "dify-file-ref:canonical"},
|
||||||
|
)
|
||||||
|
assert result.url == "https://files.example/files/tools/report.txt?timestamp=1&sign=2&as_attachment=true"
|
||||||
|
|
||||||
|
|
||||||
|
def test_file_request_rejection_maps_to_download_unavailable(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
request_download = MagicMock(side_effect=ValueError("reference is not accessible"))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sandbox_module,
|
||||||
|
"FileRequestService",
|
||||||
|
lambda: SimpleNamespace(request_download=request_download),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(AgentSandboxInspectorError) as exc_info:
|
||||||
|
sandbox_module._download_response(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
account_id="account-1",
|
||||||
|
reference="dify-file-ref:untrusted",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.code == "binding_file_download_unavailable"
|
||||||
|
assert exc_info.value.status_code == 502
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"execution",
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
SimpleNamespace(
|
||||||
|
agent_workspace_binding_id=None,
|
||||||
|
process_data_dict={"workflow_agent_binding_id": "workflow-binding-1"},
|
||||||
|
),
|
||||||
|
id="missing-binding-pointer",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
SimpleNamespace(
|
||||||
|
agent_workspace_binding_id="binding-workflow",
|
||||||
|
process_data_dict={},
|
||||||
|
),
|
||||||
|
id="missing-process-data",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_workflow_download_rejects_missing_binding_metadata_before_network(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
execution: SimpleNamespace,
|
||||||
|
) -> None:
|
||||||
|
session = MagicMock()
|
||||||
|
session.scalar.return_value = execution
|
||||||
|
_use_session(monkeypatch, session)
|
||||||
|
client = MagicMock()
|
||||||
|
|
||||||
|
with pytest.raises(AgentSandboxInspectorError) as exc_info:
|
||||||
|
WorkflowAgentSandboxService(client_factory=lambda: nullcontext(client)).download_file(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
app_id="app-1",
|
||||||
|
workflow_run_id="run-1",
|
||||||
|
node_id="node-1",
|
||||||
|
node_execution_id="execution-1",
|
||||||
|
account_id="account-1",
|
||||||
|
path="report.txt",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.code == "no_active_binding"
|
||||||
|
client.download_binding_file_sync.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_workflow_download_rejects_non_active_or_mismatched_binding_before_network(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
execution = SimpleNamespace(
|
||||||
|
agent_workspace_binding_id="binding-workflow",
|
||||||
|
process_data_dict={"workflow_agent_binding_id": "workflow-binding-1"},
|
||||||
|
)
|
||||||
|
session = MagicMock()
|
||||||
|
session.scalar.return_value = execution
|
||||||
|
_use_session(monkeypatch, session)
|
||||||
|
monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", MagicMock(return_value=None))
|
||||||
|
client = MagicMock()
|
||||||
|
|
||||||
|
with pytest.raises(AgentSandboxInspectorError) as exc_info:
|
||||||
|
WorkflowAgentSandboxService(client_factory=lambda: nullcontext(client)).download_file(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
app_id="app-1",
|
||||||
|
workflow_run_id="run-1",
|
||||||
|
node_id="node-1",
|
||||||
|
node_execution_id="execution-1",
|
||||||
|
account_id="account-1",
|
||||||
|
path="report.txt",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.code == "no_active_binding"
|
||||||
|
client.download_binding_file_sync.assert_not_called()
|
||||||
|
|||||||
@@ -103,14 +103,21 @@ func newFileCommand() *cobra.Command {
|
|||||||
Short: "Upload or download workflow files through the Agent Stub.",
|
Short: "Upload or download workflow files through the Agent Stub.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var noDownloadLink bool
|
||||||
upload := &cobra.Command{
|
upload := &cobra.Command{
|
||||||
Use: "upload PATH",
|
Use: "upload PATH",
|
||||||
Short: "Upload one sandbox-local file as a ToolFile output reference.",
|
Short: "Upload one sandbox-local file as a ToolFile output reference.",
|
||||||
Args: cobra.ExactArgs(1),
|
Args: cobra.ExactArgs(1),
|
||||||
RunE: withEnv(func(env *agentcli.Environment, args []string, _ *cobra.Command) error {
|
RunE: withEnv(func(env *agentcli.Environment, args []string, _ *cobra.Command) error {
|
||||||
return agentcli.RunFileUpload(env, args[0])
|
return agentcli.RunFileUpload(env, args[0], noDownloadLink)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
upload.Flags().BoolVar(
|
||||||
|
&noDownloadLink,
|
||||||
|
"no-download-link",
|
||||||
|
false,
|
||||||
|
"Skip creating a public download link after upload.",
|
||||||
|
)
|
||||||
|
|
||||||
var downloadTo string
|
var downloadTo string
|
||||||
download := &cobra.Command{
|
download := &cobra.Command{
|
||||||
|
|||||||
@@ -53,7 +53,12 @@ func TestCommandHelp(t *testing.T) {
|
|||||||
{
|
{
|
||||||
name: "file upload",
|
name: "file upload",
|
||||||
args: []string{"file", "upload", "--help"},
|
args: []string{"file", "upload", "--help"},
|
||||||
want: []string{"dify-agent file upload", "Upload one sandbox-local file"},
|
want: []string{
|
||||||
|
"dify-agent file upload",
|
||||||
|
"Upload one sandbox-local file",
|
||||||
|
"--no-download-link",
|
||||||
|
"Skip creating a public download link after upload.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "file download",
|
name: "file download",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package agentcli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -15,7 +16,7 @@ import (
|
|||||||
type FileUploadResponse struct {
|
type FileUploadResponse struct {
|
||||||
TransferMethod string `json:"transfer_method"`
|
TransferMethod string `json:"transfer_method"`
|
||||||
Reference string `json:"reference"`
|
Reference string `json:"reference"`
|
||||||
PublicDownloadURL string `json:"public_download_url"`
|
PublicDownloadURL string `json:"public_download_url,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileDownloadResponse is the response from a file download request.
|
// FileDownloadResponse is the response from a file download request.
|
||||||
@@ -27,14 +28,14 @@ type FileDownloadResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RunFileUpload executes the `file upload` command.
|
// RunFileUpload executes the `file upload` command.
|
||||||
func RunFileUpload(env *Environment, path string) error {
|
func RunFileUpload(env *Environment, path string, noDownloadLink bool) error {
|
||||||
client, err := NewStubClient(env)
|
client, err := NewStubClient(env)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer func() { _ = client.Close() }()
|
defer func() { _ = client.Close() }()
|
||||||
|
|
||||||
return runFileUpload(client, path, os.Stdout)
|
return runFileUpload(client, path, noDownloadLink, os.Stdout)
|
||||||
}
|
}
|
||||||
|
|
||||||
type fileUploadClient interface {
|
type fileUploadClient interface {
|
||||||
@@ -48,7 +49,7 @@ type fileUploadClient interface {
|
|||||||
) (*FileDownloadResponse, error)
|
) (*FileDownloadResponse, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
func runFileUpload(client fileUploadClient, path string, output io.Writer) error {
|
func runFileUpload(client fileUploadClient, path string, noDownloadLink bool, output io.Writer) error {
|
||||||
absPath, err := filepath.Abs(path)
|
absPath, err := filepath.Abs(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("resolve path: %w", err)
|
return fmt.Errorf("resolve path: %w", err)
|
||||||
@@ -83,24 +84,47 @@ func runFileUpload(client fileUploadClient, path string, output io.Writer) error
|
|||||||
if reference == "" {
|
if reference == "" {
|
||||||
return fmt.Errorf("signed file upload response is missing reference")
|
return fmt.Errorf("signed file upload response is missing reference")
|
||||||
}
|
}
|
||||||
|
if noDownloadLink && !isCanonicalDifyFileReference(reference) {
|
||||||
// Step 3: Request download URL for the uploaded file
|
return fmt.Errorf("signed file upload response has invalid reference")
|
||||||
ref := reference
|
|
||||||
dlResp, err := client.CreateFileDownloadURL(ctx, "tool_file", &ref, nil, true)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result := FileUploadResponse{
|
result := FileUploadResponse{
|
||||||
TransferMethod: "tool_file",
|
TransferMethod: "tool_file",
|
||||||
Reference: reference,
|
Reference: reference,
|
||||||
PublicDownloadURL: dlResp.DownloadURL,
|
}
|
||||||
|
if !noDownloadLink {
|
||||||
|
// Step 3: Request a browser-visible URL unless the caller only needs the
|
||||||
|
// canonical ToolFile reference.
|
||||||
|
ref := reference
|
||||||
|
dlResp, err := client.CreateFileDownloadURL(ctx, "tool_file", &ref, nil, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
result.PublicDownloadURL = dlResp.DownloadURL
|
||||||
}
|
}
|
||||||
out, _ := json.Marshal(result)
|
out, _ := json.Marshal(result)
|
||||||
_, _ = fmt.Fprintln(output, string(out))
|
_, _ = fmt.Fprintln(output, string(out))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isCanonicalDifyFileReference(reference string) bool {
|
||||||
|
encodedPayload, found := strings.CutPrefix(reference, "dify-file-ref:")
|
||||||
|
if !found || encodedPayload == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
payloadJSON, err := base64.URLEncoding.DecodeString(encodedPayload)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var payload struct {
|
||||||
|
RecordID string `json:"record_id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(payloadJSON, &payload); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return payload.RecordID != ""
|
||||||
|
}
|
||||||
|
|
||||||
// RunFileDownload executes the `file download` command.
|
// RunFileDownload executes the `file download` command.
|
||||||
func RunFileDownload(env *Environment, transferMethod string, referenceOrURL string, localDir string) error {
|
func RunFileDownload(env *Environment, transferMethod string, referenceOrURL string, localDir string) error {
|
||||||
var reference *string
|
var reference *string
|
||||||
|
|||||||
@@ -13,24 +13,48 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type fakeFileUploadClient struct {
|
type fakeFileUploadClient struct {
|
||||||
forFrontend bool
|
forFrontend bool
|
||||||
|
downloadRequestCall int
|
||||||
|
uploadResponse []byte
|
||||||
|
calls []string
|
||||||
|
filename string
|
||||||
|
mimetype string
|
||||||
|
uploadURL string
|
||||||
|
uploadedBytes []byte
|
||||||
|
downloadReference string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFileUploadClient) CreateFileUploadURL(_ context.Context, filename, mimetype string) (string, error) {
|
func (f *fakeFileUploadClient) CreateFileUploadURL(_ context.Context, filename, mimetype string) (string, error) {
|
||||||
|
f.calls = append(f.calls, "upload-request")
|
||||||
|
f.filename = filename
|
||||||
|
f.mimetype = mimetype
|
||||||
return "https://sandbox-files.example.com/files/upload/for-plugin?sign=1", nil
|
return "https://sandbox-files.example.com/files/upload/for-plugin?sign=1", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFileUploadClient) UploadFileToURL(uploadURL, filePath, filename, mimetype string) ([]byte, error) {
|
func (f *fakeFileUploadClient) UploadFileToURL(uploadURL, filePath, filename, mimetype string) ([]byte, error) {
|
||||||
return []byte(`{"reference":"dify-file-ref:canonical"}`), nil
|
f.calls = append(f.calls, "multipart-upload")
|
||||||
|
f.uploadURL = uploadURL
|
||||||
|
f.filename = filename
|
||||||
|
f.mimetype = mimetype
|
||||||
|
f.uploadedBytes, _ = os.ReadFile(filePath)
|
||||||
|
if f.uploadResponse != nil {
|
||||||
|
return f.uploadResponse, nil
|
||||||
|
}
|
||||||
|
return []byte(`{"reference":"dify-file-ref:eyJyZWNvcmRfaWQiOiJ0b29sLTEifQ=="}`), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFileUploadClient) CreateFileDownloadURL(
|
func (f *fakeFileUploadClient) CreateFileDownloadURL(
|
||||||
_ context.Context,
|
_ context.Context,
|
||||||
_ string,
|
_ string,
|
||||||
_, _ *string,
|
reference, _ *string,
|
||||||
forFrontend bool,
|
forFrontend bool,
|
||||||
) (*FileDownloadResponse, error) {
|
) (*FileDownloadResponse, error) {
|
||||||
|
f.calls = append(f.calls, "download-request")
|
||||||
f.forFrontend = forFrontend
|
f.forFrontend = forFrontend
|
||||||
|
f.downloadRequestCall++
|
||||||
|
if reference != nil {
|
||||||
|
f.downloadReference = *reference
|
||||||
|
}
|
||||||
return &FileDownloadResponse{
|
return &FileDownloadResponse{
|
||||||
Filename: "report.pdf",
|
Filename: "report.pdf",
|
||||||
MimeType: "application/pdf",
|
MimeType: "application/pdf",
|
||||||
@@ -47,20 +71,136 @@ func TestRunFileUploadReturnsFrontendDisplayURL(t *testing.T) {
|
|||||||
|
|
||||||
client := &fakeFileUploadClient{}
|
client := &fakeFileUploadClient{}
|
||||||
var output bytes.Buffer
|
var output bytes.Buffer
|
||||||
if err := runFileUpload(client, filePath, &output); err != nil {
|
if err := runFileUpload(client, filePath, false, &output); err != nil {
|
||||||
t.Fatalf("run file upload: %v", err)
|
t.Fatalf("run file upload: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !client.forFrontend {
|
if !client.forFrontend {
|
||||||
t.Fatal("download request did not select frontend display URL")
|
t.Fatal("download request did not select frontend display URL")
|
||||||
}
|
}
|
||||||
|
if got, want := strings.Join(client.calls, ","), "upload-request,multipart-upload,download-request"; got != want {
|
||||||
|
t.Fatalf("call order = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
if client.filename != "report.pdf" || client.mimetype != "application/pdf" {
|
||||||
|
t.Fatalf("upload metadata = (%q, %q), want report.pdf/application/pdf", client.filename, client.mimetype)
|
||||||
|
}
|
||||||
|
if client.uploadURL != "https://sandbox-files.example.com/files/upload/for-plugin?sign=1" {
|
||||||
|
t.Fatalf("upload URL = %q", client.uploadURL)
|
||||||
|
}
|
||||||
|
if string(client.uploadedBytes) != "report" {
|
||||||
|
t.Fatalf("uploaded bytes = %q, want report", client.uploadedBytes)
|
||||||
|
}
|
||||||
|
if client.downloadReference != "dify-file-ref:eyJyZWNvcmRfaWQiOiJ0b29sLTEifQ==" {
|
||||||
|
t.Fatalf("download reference = %q", client.downloadReference)
|
||||||
|
}
|
||||||
got := strings.TrimSpace(output.String())
|
got := strings.TrimSpace(output.String())
|
||||||
want := `{"transfer_method":"tool_file","reference":"dify-file-ref:canonical","public_download_url":"/files/tools/report.pdf?sign=2"}`
|
want := `{"transfer_method":"tool_file","reference":"dify-file-ref:eyJyZWNvcmRfaWQiOiJ0b29sLTEifQ==","public_download_url":"/files/tools/report.pdf?sign=2"}`
|
||||||
if got != want {
|
if got != want {
|
||||||
t.Fatalf("output = %s, want %s", got, want)
|
t.Fatalf("output = %s, want %s", got, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunFileUploadWithoutDownloadLinkReturnsOnlyCanonicalMapping(t *testing.T) {
|
||||||
|
filePath := filepath.Join(t.TempDir(), "report.pdf")
|
||||||
|
if err := os.WriteFile(filePath, []byte("report"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &fakeFileUploadClient{}
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := runFileUpload(client, filePath, true, &output); err != nil {
|
||||||
|
t.Fatalf("run file upload: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if client.downloadRequestCall != 0 {
|
||||||
|
t.Fatalf("download request calls = %d, want 0", client.downloadRequestCall)
|
||||||
|
}
|
||||||
|
if got, want := strings.Join(client.calls, ","), "upload-request,multipart-upload"; got != want {
|
||||||
|
t.Fatalf("call order = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
got := strings.TrimSpace(output.String())
|
||||||
|
want := `{"transfer_method":"tool_file","reference":"dify-file-ref:eyJyZWNvcmRfaWQiOiJ0b29sLTEifQ=="}`
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("output = %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunFileUploadDefaultAcceptsLegacyNonemptyReference(t *testing.T) {
|
||||||
|
filePath := filepath.Join(t.TempDir(), "report.pdf")
|
||||||
|
if err := os.WriteFile(filePath, []byte("report"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &fakeFileUploadClient{uploadResponse: []byte(`{"reference":"raw-id"}`)}
|
||||||
|
var output bytes.Buffer
|
||||||
|
err := runFileUpload(client, filePath, false, &output)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("run file upload: %v", err)
|
||||||
|
}
|
||||||
|
if client.downloadRequestCall != 1 || client.downloadReference != "raw-id" {
|
||||||
|
t.Fatalf("download request = (%d, %q), want legacy reference", client.downloadRequestCall, client.downloadReference)
|
||||||
|
}
|
||||||
|
if !strings.Contains(output.String(), `"reference":"raw-id"`) {
|
||||||
|
t.Fatalf("output = %q, want legacy reference", output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunFileUploadWithoutDownloadLinkRejectsNonCanonicalReference(t *testing.T) {
|
||||||
|
filePath := filepath.Join(t.TempDir(), "report.pdf")
|
||||||
|
if err := os.WriteFile(filePath, []byte("report"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &fakeFileUploadClient{uploadResponse: []byte(`{"reference":"raw-id"}`)}
|
||||||
|
var output bytes.Buffer
|
||||||
|
err := runFileUpload(client, filePath, true, &output)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "invalid reference") {
|
||||||
|
t.Fatalf("error = %v, want invalid reference", err)
|
||||||
|
}
|
||||||
|
if client.downloadRequestCall != 0 {
|
||||||
|
t.Fatalf("download request calls = %d, want 0", client.downloadRequestCall)
|
||||||
|
}
|
||||||
|
if output.Len() != 0 {
|
||||||
|
t.Fatalf("output = %q, want empty", output.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunFileUploadRejectsMissingReferenceInBothModes(t *testing.T) {
|
||||||
|
filePath := filepath.Join(t.TempDir(), "report.pdf")
|
||||||
|
if err := os.WriteFile(filePath, []byte("report"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, noDownloadLink := range []bool{false, true} {
|
||||||
|
client := &fakeFileUploadClient{uploadResponse: []byte(`{"reference":""}`)}
|
||||||
|
var output bytes.Buffer
|
||||||
|
err := runFileUpload(client, filePath, noDownloadLink, &output)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "missing reference") {
|
||||||
|
t.Fatalf("noDownloadLink=%t error = %v, want missing reference", noDownloadLink, err)
|
||||||
|
}
|
||||||
|
if client.downloadRequestCall != 0 {
|
||||||
|
t.Fatalf("noDownloadLink=%t download request calls = %d, want 0", noDownloadLink, client.downloadRequestCall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunFileUploadRejectsInvalidUploadResponseBeforeDownloadRequest(t *testing.T) {
|
||||||
|
filePath := filepath.Join(t.TempDir(), "report.pdf")
|
||||||
|
if err := os.WriteFile(filePath, []byte("report"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &fakeFileUploadClient{uploadResponse: []byte("not-json")}
|
||||||
|
var output bytes.Buffer
|
||||||
|
err := runFileUpload(client, filePath, true, &output)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "parse upload result") {
|
||||||
|
t.Fatalf("error = %v, want parse upload result failure", err)
|
||||||
|
}
|
||||||
|
if client.downloadRequestCall != 0 {
|
||||||
|
t.Fatalf("download request calls = %d, want 0", client.downloadRequestCall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunFileDownloadRequestsSandboxURLAndWritesFile(t *testing.T) {
|
func TestRunFileDownloadRequestsSandboxURLAndWritesFile(t *testing.T) {
|
||||||
var requestPayload map[string]json.RawMessage
|
var requestPayload map[string]json.RawMessage
|
||||||
var server *httptest.Server
|
var server *httptest.Server
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package agentcli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
@@ -14,29 +16,45 @@ import (
|
|||||||
|
|
||||||
// HTTPClient wraps HTTP interactions with the Agent Stub server.
|
// HTTPClient wraps HTTP interactions with the Agent Stub server.
|
||||||
type HTTPClient struct {
|
type HTTPClient struct {
|
||||||
baseURL string
|
baseURL string
|
||||||
authJWE string
|
authJWE string
|
||||||
client *http.Client
|
client *http.Client
|
||||||
|
openUploadFile func(string) (io.ReadCloser, error)
|
||||||
|
doUploadRequest func(*http.Request) (*http.Response, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var errUploadRequestAborted = errors.New("upload request aborted")
|
||||||
|
|
||||||
// NewHTTPClient creates a new HTTP client for the Agent Stub API.
|
// NewHTTPClient creates a new HTTP client for the Agent Stub API.
|
||||||
func NewHTTPClient(env *Environment) *HTTPClient {
|
func NewHTTPClient(env *Environment) *HTTPClient {
|
||||||
return &HTTPClient{
|
return &HTTPClient{
|
||||||
baseURL: env.URL,
|
baseURL: env.URL,
|
||||||
authJWE: env.AuthJWE,
|
authJWE: env.AuthJWE,
|
||||||
client: &http.Client{Timeout: 30 * time.Second},
|
client: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
openUploadFile: openUploadSource,
|
||||||
|
doUploadRequest: doUploadRequest,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHTTPClientWithTimeout creates a client with a custom timeout.
|
// NewHTTPClientWithTimeout creates a client with a custom timeout.
|
||||||
func NewHTTPClientWithTimeout(env *Environment, timeout time.Duration) *HTTPClient {
|
func NewHTTPClientWithTimeout(env *Environment, timeout time.Duration) *HTTPClient {
|
||||||
return &HTTPClient{
|
return &HTTPClient{
|
||||||
baseURL: env.URL,
|
baseURL: env.URL,
|
||||||
authJWE: env.AuthJWE,
|
authJWE: env.AuthJWE,
|
||||||
client: &http.Client{Timeout: timeout},
|
client: &http.Client{Timeout: timeout},
|
||||||
|
openUploadFile: openUploadSource,
|
||||||
|
doUploadRequest: doUploadRequest,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func openUploadSource(path string) (io.ReadCloser, error) {
|
||||||
|
return os.Open(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func doUploadRequest(req *http.Request) (*http.Response, error) {
|
||||||
|
return (&http.Client{Timeout: 120 * time.Second}).Do(req)
|
||||||
|
}
|
||||||
|
|
||||||
// postJSON sends a POST request with JSON body and returns the response body.
|
// postJSON sends a POST request with JSON body and returns the response body.
|
||||||
func (c *HTTPClient) postJSON(path string, payload any) ([]byte, int, error) {
|
func (c *HTTPClient) postJSON(path string, payload any) ([]byte, int, error) {
|
||||||
body, err := json.Marshal(payload)
|
body, err := json.Marshal(payload)
|
||||||
@@ -149,49 +167,105 @@ func (c *HTTPClient) putJSON(path string, payload any) ([]byte, int, error) {
|
|||||||
|
|
||||||
// uploadFile uploads a file to a signed URL using multipart form.
|
// uploadFile uploads a file to a signed URL using multipart form.
|
||||||
func (c *HTTPClient) uploadFile(uploadURL string, filePath string, filename string, mimetype string) ([]byte, error) {
|
func (c *HTTPClient) uploadFile(uploadURL string, filePath string, filename string, mimetype string) ([]byte, error) {
|
||||||
file, err := os.Open(filePath)
|
file, err := c.openUploadFile(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open file: %w", err)
|
return nil, fmt.Errorf("open file: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = file.Close() }()
|
pipeReader, pipeWriter := io.Pipe()
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", uploadURL, pipeReader)
|
||||||
|
if err != nil {
|
||||||
|
_ = pipeReader.Close()
|
||||||
|
_ = pipeWriter.Close()
|
||||||
|
_ = file.Close()
|
||||||
|
return nil, errors.New("create upload request: invalid signed upload URL")
|
||||||
|
}
|
||||||
|
multipartWriter := multipart.NewWriter(pipeWriter)
|
||||||
|
req.Header.Set("Content-Type", multipartWriter.FormDataContentType())
|
||||||
|
|
||||||
|
writerDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
writeErr := writeMultipartFile(multipartWriter, file, filename, mimetype)
|
||||||
|
if closeErr := file.Close(); writeErr == nil && closeErr != nil {
|
||||||
|
writeErr = fmt.Errorf("close file: %w", closeErr)
|
||||||
|
}
|
||||||
|
if writeErr != nil {
|
||||||
|
_ = pipeWriter.CloseWithError(writeErr)
|
||||||
|
} else {
|
||||||
|
writeErr = pipeWriter.Close()
|
||||||
|
}
|
||||||
|
writerDone <- writeErr
|
||||||
|
}()
|
||||||
|
|
||||||
|
resp, requestErr := c.doUploadRequest(req)
|
||||||
|
_ = pipeReader.CloseWithError(errUploadRequestAborted)
|
||||||
|
|
||||||
|
const maxUploadResponseBytes = 1024 * 1024
|
||||||
|
var respBody []byte
|
||||||
|
var responseErr error
|
||||||
|
if resp != nil {
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
respBody, responseErr = io.ReadAll(io.LimitReader(resp.Body, maxUploadResponseBytes+1))
|
||||||
|
}
|
||||||
|
|
||||||
|
writerErr := <-writerDone
|
||||||
|
if resp != nil && (resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices) {
|
||||||
|
if len(respBody) > maxUploadResponseBytes {
|
||||||
|
respBody = respBody[:maxUploadResponseBytes]
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
if requestErr != nil {
|
||||||
|
if writerErr != nil && !isUploadWriterAbort(writerErr) {
|
||||||
|
return nil, writerErr
|
||||||
|
}
|
||||||
|
if errors.Is(requestErr, context.DeadlineExceeded) || os.IsTimeout(requestErr) {
|
||||||
|
return nil, errors.New("upload request timed out")
|
||||||
|
}
|
||||||
|
return nil, errors.New("upload request failed")
|
||||||
|
}
|
||||||
|
if writerErr != nil && !isUploadWriterAbort(writerErr) {
|
||||||
|
return nil, writerErr
|
||||||
|
}
|
||||||
|
if isUploadWriterAbort(writerErr) {
|
||||||
|
return nil, errors.New("upload request completed before multipart body was fully written")
|
||||||
|
}
|
||||||
|
if responseErr != nil {
|
||||||
|
return nil, fmt.Errorf("read upload response: %w", responseErr)
|
||||||
|
}
|
||||||
|
if len(respBody) > maxUploadResponseBytes {
|
||||||
|
return nil, fmt.Errorf("upload response exceeds %d bytes", maxUploadResponseBytes)
|
||||||
|
}
|
||||||
|
return respBody, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isUploadWriterAbort(err error) bool {
|
||||||
|
return errors.Is(err, errUploadRequestAborted) || errors.Is(err, io.ErrClosedPipe)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeMultipartFile(
|
||||||
|
writer *multipart.Writer,
|
||||||
|
file io.Reader,
|
||||||
|
filename string,
|
||||||
|
mimetype string,
|
||||||
|
) (resultErr error) {
|
||||||
|
defer func() {
|
||||||
|
if closeErr := writer.Close(); resultErr == nil && closeErr != nil {
|
||||||
|
resultErr = fmt.Errorf("close multipart writer: %w", closeErr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
var buf bytes.Buffer
|
|
||||||
writer := multipart.NewWriter(&buf)
|
|
||||||
h := make(textproto.MIMEHeader)
|
h := make(textproto.MIMEHeader)
|
||||||
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, filename))
|
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, filename))
|
||||||
h.Set("Content-Type", mimetype)
|
h.Set("Content-Type", mimetype)
|
||||||
part, err := writer.CreatePart(h)
|
part, err := writer.CreatePart(h)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("create form file: %w", err)
|
return fmt.Errorf("create form file: %w", err)
|
||||||
}
|
}
|
||||||
if _, err := io.Copy(part, file); err != nil {
|
if _, err := io.Copy(part, file); err != nil {
|
||||||
return nil, fmt.Errorf("copy file content: %w", err)
|
return fmt.Errorf("copy file content: %w", err)
|
||||||
}
|
}
|
||||||
if err := writer.Close(); err != nil {
|
return nil
|
||||||
return nil, fmt.Errorf("close multipart writer: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
uploadClient := &http.Client{Timeout: 120 * time.Second}
|
|
||||||
req, err := http.NewRequest("POST", uploadURL, &buf)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("create upload request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
||||||
|
|
||||||
resp, err := uploadClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("upload request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer func() { _ = resp.Body.Close() }()
|
|
||||||
|
|
||||||
respBody, err := io.ReadAll(resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("read upload response: %w", err)
|
|
||||||
}
|
|
||||||
if resp.StatusCode >= 400 {
|
|
||||||
return nil, fmt.Errorf("upload failed with status %d: %s", resp.StatusCode, string(respBody))
|
|
||||||
}
|
|
||||||
return respBody, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// downloadFromURL downloads bytes from a signed URL.
|
// downloadFromURL downloads bytes from a signed URL.
|
||||||
|
|||||||
@@ -0,0 +1,678 @@
|
|||||||
|
package agentcli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const fifoTestDeadline = 3 * time.Second
|
||||||
|
|
||||||
|
func receiveWithin[T any](ch <-chan T, timeout time.Duration) (T, bool) {
|
||||||
|
timer := time.NewTimer(timeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case value := <-ch:
|
||||||
|
return value, true
|
||||||
|
case <-timer.C:
|
||||||
|
var zero T
|
||||||
|
return zero, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type multipartWriteRecorder struct {
|
||||||
|
headerErr error
|
||||||
|
cleanupErr error
|
||||||
|
source *terminalRecordingReader
|
||||||
|
headerAttempted bool
|
||||||
|
headerFailed bool
|
||||||
|
cleanupAttempted bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *multipartWriteRecorder) Write(p []byte) (int, error) {
|
||||||
|
if w.headerFailed || (w.source != nil && w.source.finished) {
|
||||||
|
w.cleanupAttempted = true
|
||||||
|
if w.cleanupErr != nil {
|
||||||
|
return 0, w.cleanupErr
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
if !w.headerAttempted {
|
||||||
|
w.headerAttempted = true
|
||||||
|
if w.headerErr != nil {
|
||||||
|
w.headerFailed = true
|
||||||
|
return 0, w.headerErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type terminalRecordingReader struct {
|
||||||
|
reader io.Reader
|
||||||
|
terminalErr error
|
||||||
|
finished bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *terminalRecordingReader) Read(p []byte) (int, error) {
|
||||||
|
n, err := r.reader.Read(p)
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
r.finished = true
|
||||||
|
if r.terminalErr != nil {
|
||||||
|
return n, r.terminalErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type dataThenErrorReader struct {
|
||||||
|
data []byte
|
||||||
|
sourceErr error
|
||||||
|
delivered bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type closeRecordingBody struct {
|
||||||
|
reader io.Reader
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type gatedUploadSource struct {
|
||||||
|
payload []byte
|
||||||
|
started chan struct{}
|
||||||
|
release chan struct{}
|
||||||
|
completed chan struct{}
|
||||||
|
releaseOnce sync.Once
|
||||||
|
delivered bool
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGatedUploadSource(payload []byte) *gatedUploadSource {
|
||||||
|
return &gatedUploadSource{
|
||||||
|
payload: payload,
|
||||||
|
started: make(chan struct{}),
|
||||||
|
release: make(chan struct{}),
|
||||||
|
completed: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *gatedUploadSource) Read(p []byte) (int, error) {
|
||||||
|
if s.delivered {
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
s.delivered = true
|
||||||
|
close(s.started)
|
||||||
|
<-s.release
|
||||||
|
n := copy(p, s.payload)
|
||||||
|
close(s.completed)
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *gatedUploadSource) Close() error {
|
||||||
|
s.unblock()
|
||||||
|
s.closed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *gatedUploadSource) unblock() {
|
||||||
|
s.releaseOnce.Do(func() { close(s.release) })
|
||||||
|
}
|
||||||
|
|
||||||
|
type releaseOnReadBody struct {
|
||||||
|
reader io.Reader
|
||||||
|
release func()
|
||||||
|
closed bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *releaseOnReadBody) Read(p []byte) (int, error) {
|
||||||
|
b.release()
|
||||||
|
return b.reader.Read(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *releaseOnReadBody) Close() error {
|
||||||
|
b.release()
|
||||||
|
b.closed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *closeRecordingBody) Read(p []byte) (int, error) {
|
||||||
|
return b.reader.Read(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *closeRecordingBody) Close() error {
|
||||||
|
b.closed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *dataThenErrorReader) Read(p []byte) (int, error) {
|
||||||
|
if r.delivered {
|
||||||
|
return 0, r.sourceErr
|
||||||
|
}
|
||||||
|
r.delivered = true
|
||||||
|
return copy(p, r.data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadFileStreamsMultipartBody(t *testing.T) {
|
||||||
|
payload := bytes.Repeat([]byte("streamed-payload-"), 128*1024)
|
||||||
|
filePath := filepath.Join(t.TempDir(), "payload.bin")
|
||||||
|
if err := os.WriteFile(filePath, payload, 0o600); err != nil {
|
||||||
|
t.Fatalf("write fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.ContentLength != -1 {
|
||||||
|
t.Errorf("content length = %d, want -1 for streamed request", r.ContentLength)
|
||||||
|
}
|
||||||
|
reader, err := r.MultipartReader()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("create multipart reader: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
part, err := reader.NextPart()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("read multipart part: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = part.Close() }()
|
||||||
|
if part.FormName() != "file" || part.FileName() != "payload.bin" {
|
||||||
|
http.Error(w, "unexpected multipart metadata", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if got := part.Header.Get("Content-Type"); got != "application/octet-stream" {
|
||||||
|
http.Error(w, "unexpected multipart content type: "+got, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
got, err := io.ReadAll(part)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("read multipart content: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !bytes.Equal(got, payload) {
|
||||||
|
http.Error(w, "multipart content mismatch", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"reference":"dify-file-ref:canonical"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewHTTPClient(&Environment{})
|
||||||
|
body, err := client.uploadFile(server.URL, filePath, "payload.bin", "application/octet-stream")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upload file: %v", err)
|
||||||
|
}
|
||||||
|
if string(body) != `{"reference":"dify-file-ref:canonical"}` {
|
||||||
|
t.Fatalf("response body = %s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadFileStartsRequestBeforeSourceEOF(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("uses a local HTTP server and FIFO coordination")
|
||||||
|
}
|
||||||
|
mkfifo, err := exec.LookPath("mkfifo")
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("mkfifo is unavailable")
|
||||||
|
}
|
||||||
|
fifoPath := filepath.Join(t.TempDir(), "stream.bin")
|
||||||
|
if err := exec.Command(mkfifo, "-m", "600", fifoPath).Run(); err != nil {
|
||||||
|
t.Fatalf("create FIFO: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
requestStarted := make(chan struct{})
|
||||||
|
continueSource := make(chan struct{})
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
close(requestStarted)
|
||||||
|
reader, err := r.MultipartReader()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
part, err := reader.NextPart()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = part.Close() }()
|
||||||
|
body, err := io.ReadAll(part)
|
||||||
|
if err != nil || string(body) != "prefix-suffix" {
|
||||||
|
http.Error(w, "unexpected streamed body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"reference":"dify-file-ref:eyJyZWNvcmRfaWQiOiJ0b29sLTEifQ=="}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
writerDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
file, err := os.OpenFile(fifoPath, os.O_WRONLY, 0)
|
||||||
|
if err != nil {
|
||||||
|
writerDone <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err = file.WriteString("prefix-"); err == nil {
|
||||||
|
<-continueSource
|
||||||
|
_, err = file.WriteString("suffix")
|
||||||
|
}
|
||||||
|
if closeErr := file.Close(); err == nil {
|
||||||
|
err = closeErr
|
||||||
|
}
|
||||||
|
writerDone <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
uploadDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
client := NewHTTPClient(&Environment{})
|
||||||
|
_, err := client.uploadFile(server.URL, fifoPath, "stream.bin", "application/octet-stream")
|
||||||
|
uploadDone <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
releaseSource := sync.OnceFunc(func() { close(continueSource) })
|
||||||
|
writerJoined := false
|
||||||
|
uploadJoined := false
|
||||||
|
defer func() {
|
||||||
|
releaseSource()
|
||||||
|
server.CloseClientConnections()
|
||||||
|
if !writerJoined {
|
||||||
|
if err, ok := receiveWithin(writerDone, fifoTestDeadline); !ok {
|
||||||
|
t.Errorf("cleanup: FIFO writer did not finish within %s", fifoTestDeadline)
|
||||||
|
} else if err != nil {
|
||||||
|
t.Errorf("cleanup: FIFO writer failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !uploadJoined {
|
||||||
|
if err, ok := receiveWithin(uploadDone, fifoTestDeadline); !ok {
|
||||||
|
t.Errorf("cleanup: upload did not finish within %s", fifoTestDeadline)
|
||||||
|
} else if err != nil {
|
||||||
|
t.Errorf("cleanup: upload failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if _, ok := receiveWithin(requestStarted, fifoTestDeadline); !ok {
|
||||||
|
t.Fatalf("HTTP request did not start within %s before the source reached EOF", fifoTestDeadline)
|
||||||
|
}
|
||||||
|
releaseSource()
|
||||||
|
|
||||||
|
writerErr, ok := receiveWithin(writerDone, fifoTestDeadline)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("FIFO writer did not finish within %s after source release", fifoTestDeadline)
|
||||||
|
}
|
||||||
|
writerJoined = true
|
||||||
|
if writerErr != nil {
|
||||||
|
t.Fatalf("write FIFO: %v", writerErr)
|
||||||
|
}
|
||||||
|
uploadErr, ok := receiveWithin(uploadDone, fifoTestDeadline)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("upload did not finish within %s after source EOF", fifoTestDeadline)
|
||||||
|
}
|
||||||
|
uploadJoined = true
|
||||||
|
if uploadErr != nil {
|
||||||
|
t.Fatalf("upload FIFO: %v", uploadErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadFileTransportFailureDoesNotBlockWriter(t *testing.T) {
|
||||||
|
const querySecret = "signed-upload-credential"
|
||||||
|
uploadURL := "https://upload.example/path?X-Amz-Credential=" + querySecret
|
||||||
|
source := &closeRecordingBody{reader: strings.NewReader("payload")}
|
||||||
|
client := NewHTTPClient(&Environment{})
|
||||||
|
client.openUploadFile = func(string) (io.ReadCloser, error) {
|
||||||
|
return source, nil
|
||||||
|
}
|
||||||
|
requestBodyReady := make(chan io.ReadCloser, 1)
|
||||||
|
client.doUploadRequest = func(req *http.Request) (*http.Response, error) {
|
||||||
|
requestBodyReady <- req.Body
|
||||||
|
return nil, errors.New("deterministic transport failure")
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := client.uploadFile(uploadURL, "source-path", "payload.bin", "application/octet-stream")
|
||||||
|
uploadDone <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
var requestBody io.ReadCloser
|
||||||
|
uploadJoined := false
|
||||||
|
defer func() {
|
||||||
|
_ = source.Close()
|
||||||
|
if requestBody == nil {
|
||||||
|
requestBody, _ = receiveWithin(requestBodyReady, fifoTestDeadline)
|
||||||
|
}
|
||||||
|
if requestBody != nil {
|
||||||
|
_ = requestBody.Close()
|
||||||
|
}
|
||||||
|
if !uploadJoined {
|
||||||
|
if _, ok := receiveWithin(uploadDone, fifoTestDeadline); !ok {
|
||||||
|
t.Errorf("cleanup: transport-failure upload did not finish within %s", fifoTestDeadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var ok bool
|
||||||
|
requestBody, ok = receiveWithin(requestBodyReady, fifoTestDeadline)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("upload request did not start within %s", fifoTestDeadline)
|
||||||
|
}
|
||||||
|
|
||||||
|
err, ok := receiveWithin(uploadDone, fifoTestDeadline)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("transport-failure upload did not finish within %s", fifoTestDeadline)
|
||||||
|
}
|
||||||
|
uploadJoined = true
|
||||||
|
|
||||||
|
if err == nil || err.Error() != "upload request failed" {
|
||||||
|
t.Fatalf("error = %v, want upload request failure", err)
|
||||||
|
}
|
||||||
|
if !source.closed {
|
||||||
|
t.Fatal("source file was not closed after transport failure")
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), uploadURL) || strings.Contains(err.Error(), querySecret) {
|
||||||
|
t.Fatalf("error leaked signed upload URL credentials: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadFileInvalidSignedURLDoesNotLeakCredentials(t *testing.T) {
|
||||||
|
filePath := filepath.Join(t.TempDir(), "payload.txt")
|
||||||
|
if err := os.WriteFile(filePath, []byte("payload"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write fixture: %v", err)
|
||||||
|
}
|
||||||
|
const querySecret = "signed-upload-credential"
|
||||||
|
uploadURL := "http://example.test/upload?X-Amz-Credential=" + querySecret + "\n"
|
||||||
|
|
||||||
|
client := NewHTTPClient(&Environment{})
|
||||||
|
_, err := client.uploadFile(uploadURL, filePath, "payload.txt", "text/plain")
|
||||||
|
if err == nil || err.Error() != "create upload request: invalid signed upload URL" {
|
||||||
|
t.Fatalf("error = %v, want invalid signed upload URL failure", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), uploadURL) || strings.Contains(err.Error(), querySecret) {
|
||||||
|
t.Fatalf("error leaked signed upload URL credentials: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadFileRejectsOversizedResponse(t *testing.T) {
|
||||||
|
filePath := filepath.Join(t.TempDir(), "payload.txt")
|
||||||
|
if err := os.WriteFile(filePath, []byte("payload"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
|
_, _ = w.Write(bytes.Repeat([]byte("x"), 1024*1024+1))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewHTTPClient(&Environment{})
|
||||||
|
_, err := client.uploadFile(server.URL, filePath, "payload.txt", "text/plain")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "upload response exceeds") {
|
||||||
|
t.Fatalf("error = %v, want bounded-response failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadFileReturnsNonSuccessStatus(t *testing.T) {
|
||||||
|
filePath := filepath.Join(t.TempDir(), "payload.txt")
|
||||||
|
if err := os.WriteFile(filePath, []byte("payload"), 0o600); err != nil {
|
||||||
|
t.Fatalf("write fixture: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
|
http.Error(w, "too large", http.StatusRequestEntityTooLarge)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewHTTPClient(&Environment{})
|
||||||
|
_, err := client.uploadFile(server.URL, filePath, "payload.txt", "text/plain")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "upload failed with status 413") {
|
||||||
|
t.Fatalf("error = %v, want non-success status", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uploadFileWithGatedEarlyResponse(t *testing.T, statusCode int, body string) error {
|
||||||
|
t.Helper()
|
||||||
|
source := newGatedUploadSource([]byte("payload"))
|
||||||
|
responseBody := &releaseOnReadBody{reader: strings.NewReader(body), release: source.unblock}
|
||||||
|
requestDone := make(chan error, 1)
|
||||||
|
var orderingErr error
|
||||||
|
client := NewHTTPClient(&Environment{})
|
||||||
|
client.openUploadFile = func(string) (io.ReadCloser, error) {
|
||||||
|
return source, nil
|
||||||
|
}
|
||||||
|
client.doUploadRequest = func(req *http.Request) (*http.Response, error) {
|
||||||
|
go func() {
|
||||||
|
_, err := io.Copy(io.Discard, req.Body)
|
||||||
|
requestDone <- err
|
||||||
|
}()
|
||||||
|
if _, ok := receiveWithin(source.started, fifoTestDeadline); !ok {
|
||||||
|
orderingErr = errors.New("upload source did not start before the early response")
|
||||||
|
source.unblock()
|
||||||
|
return nil, orderingErr
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-source.completed:
|
||||||
|
orderingErr = errors.New("upload source completed before the early response")
|
||||||
|
return nil, orderingErr
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return &http.Response{StatusCode: statusCode, Body: responseBody}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadDone := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
_, err := client.uploadFile("https://upload.example/path", "source-path", "payload.bin", "application/octet-stream")
|
||||||
|
uploadDone <- err
|
||||||
|
}()
|
||||||
|
uploadJoined := false
|
||||||
|
requestJoined := false
|
||||||
|
defer func() {
|
||||||
|
source.unblock()
|
||||||
|
if !uploadJoined {
|
||||||
|
if _, ok := receiveWithin(uploadDone, fifoTestDeadline); !ok {
|
||||||
|
t.Errorf("cleanup: early-response upload did not finish within %s", fifoTestDeadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !requestJoined {
|
||||||
|
if _, ok := receiveWithin(requestDone, fifoTestDeadline); !ok {
|
||||||
|
t.Errorf("cleanup: early-response request drain did not finish within %s", fifoTestDeadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
uploadErr, ok := receiveWithin(uploadDone, fifoTestDeadline)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("early-response upload did not finish within %s", fifoTestDeadline)
|
||||||
|
}
|
||||||
|
uploadJoined = true
|
||||||
|
if _, ok := receiveWithin(requestDone, fifoTestDeadline); !ok {
|
||||||
|
t.Fatalf("early-response request drain did not finish within %s", fifoTestDeadline)
|
||||||
|
}
|
||||||
|
requestJoined = true
|
||||||
|
if orderingErr != nil {
|
||||||
|
t.Fatalf("invalid early-response ordering: %v", orderingErr)
|
||||||
|
}
|
||||||
|
if _, ok := receiveWithin(source.completed, fifoTestDeadline); !ok {
|
||||||
|
t.Fatalf("upload source did not finish within %s after response processing", fifoTestDeadline)
|
||||||
|
}
|
||||||
|
if !source.closed {
|
||||||
|
t.Fatal("upload source was not closed before uploadFile returned")
|
||||||
|
}
|
||||||
|
if !responseBody.closed {
|
||||||
|
t.Fatal("early response body was not closed before uploadFile returned")
|
||||||
|
}
|
||||||
|
return uploadErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadFileReturnsEarlyNonSuccessStatusInsteadOfWriterAbort(t *testing.T) {
|
||||||
|
err := uploadFileWithGatedEarlyResponse(
|
||||||
|
t,
|
||||||
|
http.StatusRequestEntityTooLarge,
|
||||||
|
"too large without reading body\n",
|
||||||
|
)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "upload failed with status 413: too large without reading body") {
|
||||||
|
t.Fatalf("error = %v, want early HTTP 413 response", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "upload request aborted") || strings.Contains(err.Error(), "closed pipe") {
|
||||||
|
t.Fatalf("error exposed internal multipart abort: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadFileRejectsEarlySuccessBeforeMultipartCompletes(t *testing.T) {
|
||||||
|
err := uploadFileWithGatedEarlyResponse(t, http.StatusOK, `{"reference":"incomplete"}`)
|
||||||
|
if err == nil || err.Error() != "upload request completed before multipart body was fully written" {
|
||||||
|
t.Fatalf("error = %v, want incomplete multipart failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadFilePropagatesSourceReadFailure(t *testing.T) {
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
|
_, _ = w.Write([]byte(`{"reference":"unused"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
client := NewHTTPClient(&Environment{})
|
||||||
|
_, err := client.uploadFile(server.URL, t.TempDir(), "directory", "application/octet-stream")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "copy file content") {
|
||||||
|
t.Fatalf("error = %v, want source read failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadFileClosesResourcesAndJoinsWriterOnResponseOutcomes(t *testing.T) {
|
||||||
|
responseReadErr := errors.New("response read failed")
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
statusCode int
|
||||||
|
responseReader io.Reader
|
||||||
|
wantError string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "success",
|
||||||
|
statusCode: http.StatusOK,
|
||||||
|
responseReader: strings.NewReader(`{"reference":"dify-file-ref:canonical"}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-2xx",
|
||||||
|
statusCode: http.StatusRequestEntityTooLarge,
|
||||||
|
responseReader: strings.NewReader("too large"),
|
||||||
|
wantError: "upload failed with status 413",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "response body read error",
|
||||||
|
statusCode: http.StatusOK,
|
||||||
|
responseReader: &dataThenErrorReader{data: []byte("partial"), sourceErr: responseReadErr},
|
||||||
|
wantError: "read upload response: response read failed",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
source := &closeRecordingBody{reader: strings.NewReader("payload")}
|
||||||
|
var responseBody *closeRecordingBody
|
||||||
|
client := NewHTTPClient(&Environment{})
|
||||||
|
client.openUploadFile = func(path string) (io.ReadCloser, error) {
|
||||||
|
if path != "source-path" {
|
||||||
|
t.Fatalf("source path = %q", path)
|
||||||
|
}
|
||||||
|
return source, nil
|
||||||
|
}
|
||||||
|
client.doUploadRequest = func(req *http.Request) (*http.Response, error) {
|
||||||
|
if _, err := io.Copy(io.Discard, req.Body); err != nil {
|
||||||
|
t.Fatalf("drain request body: %v", err)
|
||||||
|
}
|
||||||
|
responseBody = &closeRecordingBody{reader: tt.responseReader}
|
||||||
|
return &http.Response{StatusCode: tt.statusCode, Body: responseBody}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := client.uploadFile("https://upload.example/path", "source-path", "payload.txt", "text/plain")
|
||||||
|
|
||||||
|
if tt.wantError == "" {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("upload file: %v", err)
|
||||||
|
}
|
||||||
|
} else if err == nil || !strings.Contains(err.Error(), tt.wantError) {
|
||||||
|
t.Fatalf("error = %v, want containing %q", err, tt.wantError)
|
||||||
|
}
|
||||||
|
if !source.closed {
|
||||||
|
t.Fatal("source file was not closed before uploadFile returned")
|
||||||
|
}
|
||||||
|
if responseBody == nil || !responseBody.closed {
|
||||||
|
t.Fatal("response body was not closed before uploadFile returned")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteMultipartFileAlwaysAttemptsCloseAndPreservesPrimaryError(t *testing.T) {
|
||||||
|
t.Run("create part failure", func(t *testing.T) {
|
||||||
|
createErr := errors.New("create part failed")
|
||||||
|
closeErr := errors.New("close failed")
|
||||||
|
destination := &multipartWriteRecorder{headerErr: createErr, cleanupErr: closeErr}
|
||||||
|
writer := multipart.NewWriter(destination)
|
||||||
|
|
||||||
|
err := writeMultipartFile(
|
||||||
|
writer,
|
||||||
|
strings.NewReader("payload"),
|
||||||
|
"payload.txt",
|
||||||
|
"text/plain",
|
||||||
|
)
|
||||||
|
|
||||||
|
if !errors.Is(err, createErr) {
|
||||||
|
t.Fatalf("error = %v, want CreatePart failure", err)
|
||||||
|
}
|
||||||
|
if !destination.headerAttempted || !destination.cleanupAttempted {
|
||||||
|
t.Fatal("multipart header and cleanup were not both attempted")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("copy failure", func(t *testing.T) {
|
||||||
|
sourceErr := errors.New("source read failed")
|
||||||
|
closeErr := errors.New("close failed")
|
||||||
|
source := &terminalRecordingReader{reader: strings.NewReader("payload"), terminalErr: sourceErr}
|
||||||
|
destination := &multipartWriteRecorder{cleanupErr: closeErr, source: source}
|
||||||
|
writer := multipart.NewWriter(destination)
|
||||||
|
|
||||||
|
err := writeMultipartFile(
|
||||||
|
writer,
|
||||||
|
source,
|
||||||
|
"payload.txt",
|
||||||
|
"text/plain",
|
||||||
|
)
|
||||||
|
|
||||||
|
if !errors.Is(err, sourceErr) {
|
||||||
|
t.Fatalf("error = %v, want source copy failure", err)
|
||||||
|
}
|
||||||
|
if !destination.cleanupAttempted {
|
||||||
|
t.Fatal("multipart cleanup was not attempted after the source read failure")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("close failure", func(t *testing.T) {
|
||||||
|
closeErr := errors.New("close failed")
|
||||||
|
source := &terminalRecordingReader{reader: strings.NewReader("payload")}
|
||||||
|
destination := &multipartWriteRecorder{cleanupErr: closeErr, source: source}
|
||||||
|
writer := multipart.NewWriter(destination)
|
||||||
|
|
||||||
|
err := writeMultipartFile(
|
||||||
|
writer,
|
||||||
|
source,
|
||||||
|
"payload.txt",
|
||||||
|
"text/plain",
|
||||||
|
)
|
||||||
|
|
||||||
|
if !errors.Is(err, closeErr) || !strings.Contains(err.Error(), "close multipart writer") {
|
||||||
|
t.Fatalf("error = %v, want multipart Close failure", err)
|
||||||
|
}
|
||||||
|
if !destination.cleanupAttempted {
|
||||||
|
t.Fatal("multipart cleanup was not attempted after source EOF")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -48,9 +48,6 @@ DIFY_AGENT_E2B_TEMPLATE=difys-default-team/dify-agent-local-sandbox
|
|||||||
DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS=3600
|
DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS=3600
|
||||||
DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN=
|
DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN=
|
||||||
DIFY_AGENT_E2B_SHELLCTL_PORT=5004
|
DIFY_AGENT_E2B_SHELLCTL_PORT=5004
|
||||||
# Maximum whole-file size read through /workspace/files for an Agent Stub ToolFile upload (50 MiB).
|
|
||||||
# The environment variable keeps its existing SANDBOX name for deployment compatibility.
|
|
||||||
DIFY_AGENT_SANDBOX_FILE_UPLOAD_MAX_BYTES=52428800
|
|
||||||
# JSON array of regex patterns to redact from shell output shown to the agent.
|
# JSON array of regex patterns to redact from shell output shown to the agent.
|
||||||
DIFY_AGENT_SHELL_REDACT_PATTERNS=
|
DIFY_AGENT_SHELL_REDACT_PATTERNS=
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ backend ref to the `dify.runtime` layer:
|
|||||||
flowchart LR
|
flowchart LR
|
||||||
EC["dify.execution_context<br/>request identity"]
|
EC["dify.execution_context<br/>request identity"]
|
||||||
RT["dify.runtime<br/>opaque backend_binding_ref"]
|
RT["dify.runtime<br/>opaque backend_binding_ref"]
|
||||||
SH["dify.shell<br/>commands, files, and jobs"]
|
SH["dify.shell<br/>commands and jobs"]
|
||||||
|
|
||||||
EC --> SH
|
EC --> SH
|
||||||
RT --> SH
|
RT --> SH
|
||||||
@@ -41,8 +41,8 @@ the resulting `RuntimeLease` only while that context is active. The layer does
|
|||||||
not create, retire, or destroy persistent resources, and it stores no backend
|
not create, retire, or destroy persistent resources, and it stores no backend
|
||||||
SDK object in an Agenton session snapshot.
|
SDK object in an Agenton session snapshot.
|
||||||
|
|
||||||
The Shell layer consumes `RuntimeLease.commands`, `RuntimeLease.files`, and
|
The Shell layer consumes `RuntimeLease.commands` and `RuntimeLease.layout`. It
|
||||||
`RuntimeLease.layout`. It tracks only request-local shell job ids and offsets.
|
tracks only request-local shell job ids and offsets.
|
||||||
Closing a run clears that job state; it does not retire the Binding.
|
Closing a run clears that job state; it does not retire the Binding.
|
||||||
|
|
||||||
## State ownership
|
## State ownership
|
||||||
@@ -105,7 +105,7 @@ composition contains:
|
|||||||
Each Agent request acquires that ref for the duration of the run and releases it
|
Each Agent request acquires that ref for the duration of the run and releases it
|
||||||
afterward. Local release closes the operation's shellctl connection. E2B release
|
afterward. Local release closes the operation's shellctl connection. E2B release
|
||||||
also pauses the underlying E2B resource with memory preserved. A later request
|
also pauses the underlying E2B resource with memory preserved. A later request
|
||||||
or Workspace file operation acquires a new lease for the same Binding ref. If a
|
or Binding file operation acquires a new lease for the same Binding ref. If a
|
||||||
backend confirms the resource is gone, acquisition fails; it does not create an
|
backend confirms the resource is gone, acquisition fails; it does not create an
|
||||||
empty replacement Workspace.
|
empty replacement Workspace.
|
||||||
|
|
||||||
@@ -146,7 +146,7 @@ returning success. For example, E2B kills a Sandbox when its initialization
|
|||||||
fails, and Local removes paths created by an incomplete operation. This
|
fails, and Local removes paths created by an incomplete operation. This
|
||||||
backend-local cleanup does not cross the database commit boundary.
|
backend-local cleanup does not cross the database commit boundary.
|
||||||
|
|
||||||
## Workspace file boundary
|
## Binding file boundary
|
||||||
|
|
||||||
Dify API's public file APIs accept a product locator, not a Binding id or
|
Dify API's public file APIs accept a product locator, not a Binding id or
|
||||||
backend ref: a Conversation, a debug Build Draft, or a Workflow Node Execution.
|
backend ref: a Conversation, a debug Build Draft, or a Workflow Node Execution.
|
||||||
@@ -154,21 +154,20 @@ Dify API authorizes that object and resolves its associated active Binding. It
|
|||||||
does not select the latest Binding or fall back to another product context.
|
does not select the latest Binding or fall back to another product context.
|
||||||
|
|
||||||
The resolved request reaches Dify Agent through its private
|
The resolved request reaches Dify Agent through its private
|
||||||
`POST /workspace/files/list`, `POST /workspace/files/read`, and
|
`POST /execution-bindings/files/list`, `POST /execution-bindings/files/read`,
|
||||||
`POST /workspace/files/upload` endpoints. Each operation receives a
|
and `POST /execution-bindings/files/download` endpoints. Each operation receives a
|
||||||
`backend_binding_ref`, acquires a fresh RuntimeLease, performs the file action,
|
`backend_binding_ref`, acquires a fresh RuntimeLease, performs the file action,
|
||||||
and releases the lease.
|
and releases the lease.
|
||||||
|
|
||||||
`WorkspaceFileService` forwards the request path unchanged to the current
|
`BindingFileService` resolves relative paths from `workspace_dir`, `~` and
|
||||||
RuntimeLease's file capability. The backend interprets that path in its own
|
`~/...` from `home_dir`, and leaves absolute paths in the Binding filesystem
|
||||||
filesystem namespace; the service does not require a Workspace-relative path
|
namespace. It does not enforce Workspace containment or reject `..`; the
|
||||||
or enforce `workspace_dir` containment. `~` and `~/...` can therefore address
|
selected backend's isolation policy remains authoritative. List and preview
|
||||||
the lease's Home. Whether an absolute path or a path containing `..` is
|
run bounded inspection scripts through `RuntimeLease.commands`. Download runs
|
||||||
accessible is determined by the backend and its path-isolation policy, such as
|
`dify-agent file upload --no-download-link` inside the Binding so bytes stream
|
||||||
Local shellctl and Landlock isolation. Whole-file capture for Agent Stub upload
|
directly from the runtime to Dify's existing ToolFile endpoint. Dify Agent
|
||||||
is bounded by
|
returns only the canonical ToolFile reference and releases the lease before
|
||||||
`DIFY_AGENT_SANDBOX_FILE_UPLOAD_MAX_BYTES`; the environment variable keeps its
|
Dify API signs a browser URL.
|
||||||
existing name even though the route now operates through a Binding.
|
|
||||||
|
|
||||||
`RuntimeLayout.home_dir` and `RuntimeLayout.workspace_dir` are canonical paths
|
`RuntimeLayout.home_dir` and `RuntimeLayout.workspace_dir` are canonical paths
|
||||||
inside the backend execution namespace. They are not host paths, product ids,
|
inside the backend execution namespace. They are not host paths, product ids,
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN=
|
|||||||
# DIFY_AGENT_LOCAL_SANDBOX_MATERIALIZED_HOME_ROOT=/tmp/dify-agent/materialized-homes
|
# DIFY_AGENT_LOCAL_SANDBOX_MATERIALIZED_HOME_ROOT=/tmp/dify-agent/materialized-homes
|
||||||
# DIFY_AGENT_LOCAL_SANDBOX_WORKSPACE_ROOT=/tmp/dify-agent/workspaces
|
# DIFY_AGENT_LOCAL_SANDBOX_WORKSPACE_ROOT=/tmp/dify-agent/workspaces
|
||||||
# DIFY_AGENT_LOCAL_SANDBOX_HOME_SNAPSHOT_ROOT=/tmp/dify-agent/home-snapshots
|
# DIFY_AGENT_LOCAL_SANDBOX_HOME_SNAPSHOT_ROOT=/tmp/dify-agent/home-snapshots
|
||||||
DIFY_AGENT_SANDBOX_FILE_UPLOAD_MAX_BYTES=52428800
|
|
||||||
```
|
```
|
||||||
|
|
||||||
E2B requires `DIFY_AGENT_E2B_API_KEY` and defaults to the prepared
|
E2B requires `DIFY_AGENT_E2B_API_KEY` and defaults to the prepared
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ also reads `.env` and `dify-agent/.env` when present.
|
|||||||
| `DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS` | `3600` | Maximum continuous active time for the RuntimeLease spanning one complete Agent run. Binding resources pause on timeout. This is not a retention TTL. |
|
| `DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS` | `3600` | Maximum continuous active time for the RuntimeLease spanning one complete Agent run. Binding resources pause on timeout. This is not a retention TTL. |
|
||||||
| `DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN` | empty | Optional bearer token expected by shellctl inside the E2B template. |
|
| `DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN` | empty | Optional bearer token expected by shellctl inside the E2B template. |
|
||||||
| `DIFY_AGENT_E2B_SHELLCTL_PORT` | `5004` | shellctl port exposed by the E2B template. |
|
| `DIFY_AGENT_E2B_SHELLCTL_PORT` | `5004` | shellctl port exposed by the E2B template. |
|
||||||
| `DIFY_AGENT_SANDBOX_FILE_UPLOAD_MAX_BYTES` | `52428800` | Standalone Dify Agent maximum for whole-file Workspace upload capture; 50 MiB by default. Docker Compose derives it from `PLUGIN_MAX_FILE_SIZE`. |
|
|
||||||
| `DIFY_AGENT_SHELL_REDACT_PATTERNS` | empty | JSON array of additional regex patterns redacted from Shell output. |
|
| `DIFY_AGENT_SHELL_REDACT_PATTERNS` | empty | JSON array of additional regex patterns redacted from Shell output. |
|
||||||
| `DIFY_AGENT_STUB_API_BASE_URL` | empty | HTTP(S) Agent Stub API base URL reachable from the Sandbox. It may be the service root or `/agent-stub`. Enables `DIFY_AGENT_STUB_*` env injection for user `shell.run` jobs. |
|
| `DIFY_AGENT_STUB_API_BASE_URL` | empty | HTTP(S) Agent Stub API base URL reachable from the Sandbox. It may be the service root or `/agent-stub`. Enables `DIFY_AGENT_STUB_*` env injection for user `shell.run` jobs. |
|
||||||
| `DIFY_AGENT_SANDBOX_FILES_BASE_URL` | empty | Dify API base URL reachable from the Sandbox for signed `/files/*` upload/download bytes, including Config file and skill pulls. Required when Agent Stub file operations are enabled. May include an ingress path prefix, but not a query or fragment. |
|
| `DIFY_AGENT_SANDBOX_FILES_BASE_URL` | empty | Dify API base URL reachable from the Sandbox for signed `/files/*` upload/download bytes, including Config file and skill pulls. Required when Agent Stub file operations are enabled. May include an ingress path prefix, but not a query or fragment. |
|
||||||
@@ -85,7 +84,6 @@ DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN=replace-with-shellctl-token
|
|||||||
DIFY_AGENT_LOCAL_SANDBOX_MATERIALIZED_HOME_ROOT=/tmp/dify-agent/materialized-homes
|
DIFY_AGENT_LOCAL_SANDBOX_MATERIALIZED_HOME_ROOT=/tmp/dify-agent/materialized-homes
|
||||||
DIFY_AGENT_LOCAL_SANDBOX_WORKSPACE_ROOT=/tmp/dify-agent/workspaces
|
DIFY_AGENT_LOCAL_SANDBOX_WORKSPACE_ROOT=/tmp/dify-agent/workspaces
|
||||||
DIFY_AGENT_LOCAL_SANDBOX_HOME_SNAPSHOT_ROOT=/tmp/dify-agent/home-snapshots
|
DIFY_AGENT_LOCAL_SANDBOX_HOME_SNAPSHOT_ROOT=/tmp/dify-agent/home-snapshots
|
||||||
DIFY_AGENT_SANDBOX_FILE_UPLOAD_MAX_BYTES=52428800
|
|
||||||
DIFY_AGENT_STUB_API_BASE_URL=https://agent.example.com/agent-stub
|
DIFY_AGENT_STUB_API_BASE_URL=https://agent.example.com/agent-stub
|
||||||
DIFY_AGENT_SANDBOX_FILES_BASE_URL=https://dify.example.com
|
DIFY_AGENT_SANDBOX_FILES_BASE_URL=https://dify.example.com
|
||||||
# This is security-sensitive: it derives the JWE encryption key for Agent Stub bearer tokens.
|
# This is security-sensitive: it derives the JWE encryption key for Agent Stub bearer tokens.
|
||||||
@@ -125,11 +123,6 @@ must use `DIFY_AGENT_LOCAL_SANDBOX_ENDPOINT` and
|
|||||||
`DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN`. There is no compatibility setting for
|
`DIFY_AGENT_LOCAL_SANDBOX_AUTH_TOKEN`. There is no compatibility setting for
|
||||||
the removed shell-provider selector.
|
the removed shell-provider selector.
|
||||||
|
|
||||||
The example above is for a standalone Dify Agent process, where the byte limit
|
|
||||||
can be set directly. In a Docker deployment, set `PLUGIN_MAX_FILE_SIZE` in
|
|
||||||
`docker/.env`; Compose maps it to
|
|
||||||
`DIFY_AGENT_SANDBOX_FILE_UPLOAD_MAX_BYTES` inside `agent_backend`.
|
|
||||||
|
|
||||||
The backend selection is deployment-private. Shell-enabled run requests use an
|
The backend selection is deployment-private. Shell-enabled run requests use an
|
||||||
Execution Context, `dify.runtime`, and `dify.shell` graph. Runtime config carries
|
Execution Context, `dify.runtime`, and `dify.shell` graph. Runtime config carries
|
||||||
only the opaque `backend_binding_ref` resolved by Dify API. See
|
only the opaque `backend_binding_ref` resolved by Dify API. See
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Shell layer
|
# Shell layer
|
||||||
|
|
||||||
The `dify.shell` layer exposes shellctl-backed commands and files to an Agent.
|
The `dify.shell` layer exposes shellctl-backed commands to an Agent.
|
||||||
It does not select a backend or own persistent Home, Workspace, or Binding
|
It does not select a backend or own persistent Home, Workspace, or Binding
|
||||||
resources. It consumes the operation-scoped `RuntimeLease` opened by a sibling
|
resources. It consumes the operation-scoped `RuntimeLease` opened by a sibling
|
||||||
`dify.runtime` layer.
|
`dify.runtime` layer.
|
||||||
@@ -115,6 +115,12 @@ or a same-origin `/files/...` relative URI when `FILES_URL` is empty. The CLI
|
|||||||
does not access this field. The former ambiguous `download_url` upload-output
|
does not access this field. The former ambiguous `download_url` upload-output
|
||||||
key is now named `public_download_url`.
|
key is now named `public_download_url`.
|
||||||
|
|
||||||
|
Server-side Binding downloads use
|
||||||
|
`dify-agent file upload --no-download-link <path>`. This additive mode performs
|
||||||
|
the same streaming ToolFile upload but skips the download-request step and
|
||||||
|
prints only `transfer_method` plus the canonical `reference`. The regular
|
||||||
|
`file upload` command keeps the link-producing behavior shown above.
|
||||||
|
|
||||||
## Request graph
|
## Request graph
|
||||||
|
|
||||||
A shell-enabled run contains Execution Context, Runtime, and Shell layers:
|
A shell-enabled run contains Execution Context, Runtime, and Shell layers:
|
||||||
@@ -127,7 +133,7 @@ flowchart LR
|
|||||||
|
|
||||||
`DifyRuntimeLayer` acquires the Binding when the run's resource context opens
|
`DifyRuntimeLayer` acquires the Binding when the run's resource context opens
|
||||||
and releases it when that operation exits. `DifyShellLayer` uses the active
|
and releases it when that operation exits. `DifyShellLayer` uses the active
|
||||||
lease's commands, files, Home path, and Workspace path. It performs only
|
lease's commands, Home path, and Workspace path. It performs only
|
||||||
best-effort cleanup of shell jobs; the persistent Binding lifecycle remains in
|
best-effort cleanup of shell jobs; the persistent Binding lifecycle remains in
|
||||||
Dify API.
|
Dify API.
|
||||||
|
|
||||||
@@ -227,11 +233,14 @@ sent in the run request. Shell commands start in `workspace_dir`, while `HOME`
|
|||||||
is forced to `home_dir`; `~` therefore resolves to the current Binding's
|
is forced to `home_dir`; `~` therefore resolves to the current Binding's
|
||||||
materialized Home.
|
materialized Home.
|
||||||
|
|
||||||
Workspace files persist with the Workspace until Dify API retires and collects
|
Workspace content persists with the Workspace until Dify API retires and
|
||||||
it. Releasing a RuntimeLease ends only the current operation. Dify API can later
|
collects it. Releasing a RuntimeLease ends only the current operation. Dify API can later
|
||||||
browse the current Workspace through Dify Agent's private
|
browse the Binding filesystem through Dify Agent's private
|
||||||
`/workspace/files/list`, `/workspace/files/read`, and
|
`/execution-bindings/files/list`, `/execution-bindings/files/read`, and
|
||||||
`/workspace/files/upload` routes, each of which acquires a fresh lease.
|
`/execution-bindings/files/download` routes, each of which acquires a fresh
|
||||||
|
lease. Relative paths start in the Workspace, while `~` starts in the Binding's
|
||||||
|
Home. Download uses the installed CLI to stream a ToolFile upload and returns
|
||||||
|
only its canonical reference to Dify API.
|
||||||
|
|
||||||
On Local, multiple Bindings may share a Workspace while each receives a
|
On Local, multiple Bindings may share a Workspace while each receives a
|
||||||
separate materialized Home. Those directories may be siblings in one shellctl
|
separate materialized Home. Those directories may be siblings in one shellctl
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from dify_agent.adapters.shell.protocols import (
|
|||||||
ShellCommandProtocol,
|
ShellCommandProtocol,
|
||||||
ShellCommandResult,
|
ShellCommandResult,
|
||||||
ShellCommandStatus,
|
ShellCommandStatus,
|
||||||
ShellFileTransferProtocol,
|
|
||||||
ShellPromptObservation,
|
ShellPromptObservation,
|
||||||
ShellProviderError,
|
ShellProviderError,
|
||||||
)
|
)
|
||||||
@@ -28,7 +27,6 @@ __all__ = [
|
|||||||
"ShellCommandProtocol",
|
"ShellCommandProtocol",
|
||||||
"ShellCommandResult",
|
"ShellCommandResult",
|
||||||
"ShellCommandStatus",
|
"ShellCommandStatus",
|
||||||
"ShellFileTransferProtocol",
|
|
||||||
"ShellPromptObservation",
|
"ShellPromptObservation",
|
||||||
"ShellProviderError",
|
"ShellProviderError",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -105,9 +105,3 @@ class ShellCommandProtocol(Protocol):
|
|||||||
force: bool = False,
|
force: bool = False,
|
||||||
grace_seconds: float | None = None,
|
grace_seconds: float | None = None,
|
||||||
) -> None: ...
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
class ShellFileTransferProtocol(Protocol):
|
|
||||||
async def upload(self, *, content: bytes, remote_path: str, cwd: str | None = None) -> None: ...
|
|
||||||
|
|
||||||
async def download(self, *, remote_path: str, cwd: str | None = None) -> bytes: ...
|
|
||||||
|
|||||||
@@ -1,24 +1,14 @@
|
|||||||
"""Shellctl command and file data-plane adapters for RuntimeLease objects.
|
"""Shellctl command adapter for RuntimeLease objects.
|
||||||
|
|
||||||
The built-in shellctl SDK owns the HTTP timeout policy for long-polling
|
The built-in shellctl SDK owns the HTTP timeout policy for long-polling
|
||||||
shellctl requests. This adapter translates SDK and transport failures into
|
shellctl requests. This adapter translates SDK and transport failures into
|
||||||
``ShellProviderError``. Browse paths are interpreted directly inside the
|
``ShellProviderError``.
|
||||||
backend-provided filesystem namespace. Whole-file reads return bytes to the
|
|
||||||
control plane; they never create another runtime-visible upload path.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import base64
|
|
||||||
import binascii
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import posixpath
|
import posixpath
|
||||||
import re
|
from collections.abc import Awaitable, Callable
|
||||||
import shlex
|
|
||||||
import time
|
|
||||||
from collections.abc import Awaitable
|
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Protocol, TypeVar, cast
|
from typing import Protocol, TypeVar, cast
|
||||||
|
|
||||||
@@ -30,169 +20,15 @@ from dify_agent.adapters.shell.protocols import (
|
|||||||
ShellCommandProtocol,
|
ShellCommandProtocol,
|
||||||
ShellCommandResult,
|
ShellCommandResult,
|
||||||
ShellCommandStatus,
|
ShellCommandStatus,
|
||||||
ShellFileTransferProtocol,
|
|
||||||
ShellProviderError,
|
ShellProviderError,
|
||||||
)
|
)
|
||||||
from dify_agent.runtime_backend.errors import (
|
|
||||||
WorkspaceFileTooLargeError,
|
|
||||||
WorkspacePathError,
|
|
||||||
WorkspaceUnavailableError,
|
|
||||||
)
|
|
||||||
from dify_agent.runtime_backend.protocols import (
|
|
||||||
WorkspaceFileEntry,
|
|
||||||
WorkspaceFileContent,
|
|
||||||
WorkspaceListResult,
|
|
||||||
WorkspaceReadResult,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
ResultT = TypeVar("ResultT")
|
ResultT = TypeVar("ResultT")
|
||||||
|
|
||||||
_DEFAULT_TIMEOUT_SECONDS = 30.0
|
_DEFAULT_TIMEOUT_SECONDS = 30.0
|
||||||
_READ_OUTPUT_TIMEOUT_SECONDS = 0.0
|
_READ_OUTPUT_TIMEOUT_SECONDS = 0.0
|
||||||
_DEFAULT_TERMINATE_GRACE_SECONDS = 10.0
|
_DEFAULT_TERMINATE_GRACE_SECONDS = 10.0
|
||||||
_FILE_TRANSFER_TIMEOUT_SECONDS = 60.0
|
|
||||||
_SHELLCTL_OUTPUT_LIMIT_BYTES = 16 * 1024
|
_SHELLCTL_OUTPUT_LIMIT_BYTES = 16 * 1024
|
||||||
_TRANSFER_BEGIN = "<<<DIFY_SHELL_FILE_BEGIN>>>"
|
|
||||||
_TRANSFER_END = "<<<DIFY_SHELL_FILE_END>>>"
|
|
||||||
_DOWNLOAD_MISSING_EXIT_CODE = 66
|
|
||||||
_WORKSPACE_PAYLOAD_BEGIN = "<<<DIFY_WORKSPACE_BEGIN>>>"
|
|
||||||
_WORKSPACE_PAYLOAD_END = "<<<DIFY_WORKSPACE_END>>>"
|
|
||||||
|
|
||||||
_LIST_WORKSPACE_SCRIPT = r"""
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import stat
|
|
||||||
import sys
|
|
||||||
|
|
||||||
path = sys.argv[1]
|
|
||||||
limit = int(sys.argv[2])
|
|
||||||
directory_fd = None
|
|
||||||
try:
|
|
||||||
directory_fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
||||||
# DIFY_WORKSPACE_CHECKPOINT: directory_opened
|
|
||||||
names = sorted(os.listdir(directory_fd))
|
|
||||||
entries = []
|
|
||||||
for name in names[:limit]:
|
|
||||||
child_stat = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
|
|
||||||
mode = child_stat.st_mode
|
|
||||||
entry_type = (
|
|
||||||
"symlink" if stat.S_ISLNK(mode) else
|
|
||||||
"dir" if stat.S_ISDIR(mode) else
|
|
||||||
"file" if stat.S_ISREG(mode) else
|
|
||||||
"other"
|
|
||||||
)
|
|
||||||
entries.append({
|
|
||||||
"name": name,
|
|
||||||
"type": entry_type,
|
|
||||||
"size": int(child_stat.st_size),
|
|
||||||
"mtime": int(child_stat.st_mtime),
|
|
||||||
})
|
|
||||||
finally:
|
|
||||||
if directory_fd is not None:
|
|
||||||
os.close(directory_fd)
|
|
||||||
|
|
||||||
payload = {"path": path, "entries": entries, "truncated": len(names) > limit}
|
|
||||||
blob = base64.b64encode(json.dumps(payload).encode()).decode()
|
|
||||||
print("<<<DIFY_WORKSPACE_BEGIN>>>" + blob + "<<<DIFY_WORKSPACE_END>>>")
|
|
||||||
"""
|
|
||||||
|
|
||||||
_READ_WORKSPACE_SCRIPT = r"""
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import stat
|
|
||||||
import sys
|
|
||||||
|
|
||||||
path = sys.argv[1]
|
|
||||||
max_bytes = int(sys.argv[2])
|
|
||||||
file_fd = None
|
|
||||||
try:
|
|
||||||
file_fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
||||||
# DIFY_WORKSPACE_CHECKPOINT: file_opened
|
|
||||||
file_stat = os.fstat(file_fd)
|
|
||||||
if not stat.S_ISREG(file_stat.st_mode):
|
|
||||||
raise FileNotFoundError(path)
|
|
||||||
size = int(file_stat.st_size)
|
|
||||||
data = os.read(file_fd, max_bytes + 1)
|
|
||||||
finally:
|
|
||||||
if file_fd is not None:
|
|
||||||
os.close(file_fd)
|
|
||||||
|
|
||||||
truncated = len(data) > max_bytes
|
|
||||||
data = data[:max_bytes]
|
|
||||||
try:
|
|
||||||
text = data.decode("utf-8")
|
|
||||||
binary = False
|
|
||||||
except UnicodeDecodeError:
|
|
||||||
text = None
|
|
||||||
binary = True
|
|
||||||
payload = {
|
|
||||||
"path": path,
|
|
||||||
"size": size,
|
|
||||||
"truncated": truncated,
|
|
||||||
"binary": binary,
|
|
||||||
"text": text,
|
|
||||||
}
|
|
||||||
blob = base64.b64encode(json.dumps(payload).encode()).decode()
|
|
||||||
print("<<<DIFY_WORKSPACE_BEGIN>>>" + blob + "<<<DIFY_WORKSPACE_END>>>")
|
|
||||||
"""
|
|
||||||
|
|
||||||
_READ_WORKSPACE_BYTES_SCRIPT = r"""
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import stat
|
|
||||||
import sys
|
|
||||||
|
|
||||||
path = sys.argv[1]
|
|
||||||
max_bytes = int(sys.argv[2])
|
|
||||||
# DIFY_WORKSPACE_CHECKPOINT: arguments_loaded
|
|
||||||
file_fd = None
|
|
||||||
try:
|
|
||||||
file_fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
||||||
# DIFY_WORKSPACE_CHECKPOINT: file_opened
|
|
||||||
file_stat = os.fstat(file_fd)
|
|
||||||
if not stat.S_ISREG(file_stat.st_mode):
|
|
||||||
raise FileNotFoundError(path)
|
|
||||||
size = int(file_stat.st_size)
|
|
||||||
# DIFY_WORKSPACE_CHECKPOINT: file_size_captured
|
|
||||||
content = None
|
|
||||||
if size <= max_bytes:
|
|
||||||
chunks = []
|
|
||||||
remaining = max_bytes + 1
|
|
||||||
while remaining > 0:
|
|
||||||
chunk = os.read(file_fd, min(1024 * 1024, remaining))
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
chunks.append(chunk)
|
|
||||||
remaining -= len(chunk)
|
|
||||||
captured = b"".join(chunks)
|
|
||||||
if len(captured) > max_bytes:
|
|
||||||
size = max(size, len(captured))
|
|
||||||
else:
|
|
||||||
content = captured
|
|
||||||
finally:
|
|
||||||
if file_fd is not None:
|
|
||||||
os.close(file_fd)
|
|
||||||
|
|
||||||
if content is None:
|
|
||||||
payload = {"path": path, "size": size, "too_large": True}
|
|
||||||
else:
|
|
||||||
payload = {
|
|
||||||
"path": path,
|
|
||||||
"size": size,
|
|
||||||
"content_base64": base64.b64encode(content).decode("ascii"),
|
|
||||||
}
|
|
||||||
blob = base64.b64encode(json.dumps(payload).encode()).decode()
|
|
||||||
print("<<<DIFY_WORKSPACE_BEGIN>>>" + blob + "<<<DIFY_WORKSPACE_END>>>")
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class ShellFileTransferError(RuntimeError):
|
|
||||||
"""Raised when a file cannot be uploaded or downloaded through shellctl."""
|
|
||||||
|
|
||||||
|
|
||||||
class ShellctlJobResult(Protocol):
|
class ShellctlJobResult(Protocol):
|
||||||
@@ -344,160 +180,6 @@ class ShellctlCommands(ShellCommandProtocol):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class ShellctlFileTransfer(ShellFileTransferProtocol):
|
|
||||||
client: ShellctlClientProtocol
|
|
||||||
cwd: str | None = None
|
|
||||||
home_dir: str | None = None
|
|
||||||
timeout: float = _FILE_TRANSFER_TIMEOUT_SECONDS
|
|
||||||
|
|
||||||
async def list_directory(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
path: str,
|
|
||||||
limit: int,
|
|
||||||
) -> WorkspaceListResult:
|
|
||||||
payload = await self._run_workspace_script(
|
|
||||||
_LIST_WORKSPACE_SCRIPT,
|
|
||||||
args=[self._resolve_path(path), str(limit)],
|
|
||||||
)
|
|
||||||
raw_entries = payload.get("entries")
|
|
||||||
if not isinstance(raw_entries, list):
|
|
||||||
raise WorkspaceUnavailableError("workspace list returned an invalid entries payload")
|
|
||||||
entries: list[WorkspaceFileEntry] = []
|
|
||||||
for raw_entry in raw_entries:
|
|
||||||
if not isinstance(raw_entry, dict):
|
|
||||||
raise WorkspaceUnavailableError("workspace list returned an invalid entry")
|
|
||||||
entries.append(
|
|
||||||
WorkspaceFileEntry(
|
|
||||||
name=str(raw_entry.get("name", "")),
|
|
||||||
type=str(raw_entry.get("type", "other")),
|
|
||||||
size=int(raw_entry["size"]) if isinstance(raw_entry.get("size"), int) else None,
|
|
||||||
mtime=int(raw_entry["mtime"]) if isinstance(raw_entry.get("mtime"), int) else None,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return WorkspaceListResult(
|
|
||||||
path=path,
|
|
||||||
entries=tuple(entries),
|
|
||||||
truncated=payload.get("truncated") is True,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def read_file(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
path: str,
|
|
||||||
max_bytes: int,
|
|
||||||
) -> WorkspaceReadResult:
|
|
||||||
payload = await self._run_workspace_script(
|
|
||||||
_READ_WORKSPACE_SCRIPT,
|
|
||||||
args=[self._resolve_path(path), str(max_bytes)],
|
|
||||||
)
|
|
||||||
size = payload.get("size")
|
|
||||||
if not isinstance(size, int):
|
|
||||||
raise WorkspaceUnavailableError("workspace read returned an invalid size")
|
|
||||||
text = payload.get("text")
|
|
||||||
return WorkspaceReadResult(
|
|
||||||
path=path,
|
|
||||||
size=size,
|
|
||||||
truncated=payload.get("truncated") is True,
|
|
||||||
binary=payload.get("binary") is True,
|
|
||||||
text=text if isinstance(text, str) else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def read_bytes(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
path: str,
|
|
||||||
max_bytes: int,
|
|
||||||
) -> WorkspaceFileContent:
|
|
||||||
if max_bytes < 1:
|
|
||||||
raise ValueError("max_bytes must be positive")
|
|
||||||
payload = await self._run_workspace_script(
|
|
||||||
_READ_WORKSPACE_BYTES_SCRIPT,
|
|
||||||
args=[self._resolve_path(path), str(max_bytes)],
|
|
||||||
)
|
|
||||||
size = payload.get("size")
|
|
||||||
if payload.get("too_large") is True:
|
|
||||||
if not isinstance(size, int):
|
|
||||||
raise WorkspaceUnavailableError("workspace bytes read returned an invalid size")
|
|
||||||
raise WorkspaceFileTooLargeError(path=path, size=size, max_bytes=max_bytes)
|
|
||||||
encoded = payload.get("content_base64")
|
|
||||||
if not isinstance(size, int) or not isinstance(encoded, str):
|
|
||||||
raise WorkspaceUnavailableError("workspace bytes read returned an invalid payload")
|
|
||||||
try:
|
|
||||||
content = base64.b64decode(encoded, validate=True)
|
|
||||||
except (ValueError, binascii.Error) as exc:
|
|
||||||
raise WorkspaceUnavailableError("workspace bytes read returned invalid base64") from exc
|
|
||||||
if len(content) != size:
|
|
||||||
raise WorkspaceUnavailableError("workspace bytes read returned an invalid size")
|
|
||||||
return WorkspaceFileContent(
|
|
||||||
path=path,
|
|
||||||
size=size,
|
|
||||||
content=content,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _run_workspace_script(self, source: str, *, args: list[str]) -> dict[str, object]:
|
|
||||||
command = _python_stdin_command(source, args=args)
|
|
||||||
completed = await _run_to_completion(
|
|
||||||
self.client,
|
|
||||||
command,
|
|
||||||
cwd=_resolve_lease_cwd(self.cwd, home_dir=self.home_dir, workspace_dir=self.cwd),
|
|
||||||
env=_lease_env(None, home_dir=self.home_dir),
|
|
||||||
timeout=self.timeout,
|
|
||||||
)
|
|
||||||
if completed.exit_code != 0:
|
|
||||||
detail = _output_tail(completed.output)
|
|
||||||
if "PermissionError" in completed.output:
|
|
||||||
raise WorkspacePathError(detail)
|
|
||||||
raise WorkspaceUnavailableError(detail)
|
|
||||||
return _decode_workspace_payload(completed.output)
|
|
||||||
|
|
||||||
def _resolve_path(self, path: str) -> str:
|
|
||||||
if self.home_dir is None:
|
|
||||||
return path
|
|
||||||
if path == "~":
|
|
||||||
return self.home_dir
|
|
||||||
if path.startswith("~/"):
|
|
||||||
return f"{self.home_dir.rstrip('/')}/{path[2:]}"
|
|
||||||
return path
|
|
||||||
|
|
||||||
async def upload(self, *, content: bytes, remote_path: str, cwd: str | None = None) -> None:
|
|
||||||
encoded = base64.b64encode(content).decode("ascii")
|
|
||||||
completed = await _run_to_completion(
|
|
||||||
self.client,
|
|
||||||
_upload_script(remote_path=remote_path, encoded=encoded),
|
|
||||||
cwd=_resolve_lease_cwd(cwd, home_dir=self.home_dir, workspace_dir=self.cwd),
|
|
||||||
env=_lease_env(None, home_dir=self.home_dir),
|
|
||||||
timeout=self.timeout,
|
|
||||||
)
|
|
||||||
if completed.exit_code != 0:
|
|
||||||
raise ShellFileTransferError(
|
|
||||||
f"Failed to upload to {remote_path!r}: exit_code={completed.exit_code}, "
|
|
||||||
f"output={_output_tail(completed.output)!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
async def download(self, *, remote_path: str, cwd: str | None = None) -> bytes:
|
|
||||||
completed = await _run_to_completion(
|
|
||||||
self.client,
|
|
||||||
_download_script(remote_path=remote_path),
|
|
||||||
cwd=_resolve_lease_cwd(cwd, home_dir=self.home_dir, workspace_dir=self.cwd),
|
|
||||||
env=_lease_env(None, home_dir=self.home_dir),
|
|
||||||
timeout=self.timeout,
|
|
||||||
)
|
|
||||||
if completed.exit_code == _DOWNLOAD_MISSING_EXIT_CODE:
|
|
||||||
raise ShellFileTransferError(f"Remote path not found: {remote_path!r}.")
|
|
||||||
if completed.exit_code != 0:
|
|
||||||
raise ShellFileTransferError(
|
|
||||||
f"Failed to download {remote_path!r}: exit_code={completed.exit_code}, "
|
|
||||||
f"output={_output_tail(completed.output)!r}"
|
|
||||||
)
|
|
||||||
encoded = _extract_transfer_payload(completed.output)
|
|
||||||
try:
|
|
||||||
return base64.b64decode(encoded.encode("ascii"), validate=True)
|
|
||||||
except (ValueError, binascii.Error) as exc:
|
|
||||||
raise ShellFileTransferError(f"Downloaded payload for {remote_path!r} was not valid base64.") from exc
|
|
||||||
|
|
||||||
|
|
||||||
def create_default_shellctl_client_factory(
|
def create_default_shellctl_client_factory(
|
||||||
*,
|
*,
|
||||||
entrypoint: str,
|
entrypoint: str,
|
||||||
@@ -518,13 +200,6 @@ def create_default_shellctl_client_factory(
|
|||||||
return factory
|
return factory
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class _CompletedShellctlJob:
|
|
||||||
job_id: str
|
|
||||||
exit_code: int | None
|
|
||||||
output: str
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_client_call(awaitable: Awaitable[ResultT]) -> ResultT:
|
async def _run_client_call(awaitable: Awaitable[ResultT]) -> ResultT:
|
||||||
"""Map shellctl client boundary failures into provider-layer errors."""
|
"""Map shellctl client boundary failures into provider-layer errors."""
|
||||||
|
|
||||||
@@ -578,94 +253,6 @@ def _status_name(status: object) -> str:
|
|||||||
return str(status)
|
return str(status)
|
||||||
|
|
||||||
|
|
||||||
async def _run_to_completion(
|
|
||||||
client: ShellctlClientProtocol,
|
|
||||||
script: str,
|
|
||||||
*,
|
|
||||||
cwd: str | None,
|
|
||||||
env: dict[str, str] | None,
|
|
||||||
timeout: float,
|
|
||||||
) -> _CompletedShellctlJob:
|
|
||||||
deadline = time.monotonic() + timeout
|
|
||||||
job_id: str | None = None
|
|
||||||
try:
|
|
||||||
result = await _run_client_call(client.run(script, cwd=cwd, env=env, timeout=_remaining_timeout(deadline)))
|
|
||||||
parts = [result.output]
|
|
||||||
job_id = result.job_id
|
|
||||||
while not result.done or result.truncated:
|
|
||||||
result = await _run_client_call(
|
|
||||||
client.wait(job_id, offset=result.offset, timeout=_remaining_timeout(deadline))
|
|
||||||
)
|
|
||||||
parts.append(result.output)
|
|
||||||
return _CompletedShellctlJob(job_id=job_id, exit_code=result.exit_code, output="".join(parts))
|
|
||||||
finally:
|
|
||||||
if job_id is not None:
|
|
||||||
try:
|
|
||||||
await _run_client_call(client.delete(job_id, force=True))
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.warning("Failed to delete shellctl job %s: %s", job_id, exc)
|
|
||||||
|
|
||||||
|
|
||||||
def _upload_script(*, remote_path: str, encoded: str) -> str:
|
|
||||||
return (
|
|
||||||
"set -eu\n"
|
|
||||||
f'mkdir -p "$(dirname -- {_shquote(remote_path)})"\n'
|
|
||||||
f"printf %s {_shquote(encoded)} | base64 -d > {_shquote(remote_path)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _download_script(*, remote_path: str) -> str:
|
|
||||||
return "\n".join(
|
|
||||||
[
|
|
||||||
"set -eu",
|
|
||||||
f"path={_shquote(remote_path)}",
|
|
||||||
'if [ ! -f "$path" ]; then exit 66; fi',
|
|
||||||
f"printf %s {_shquote(_TRANSFER_BEGIN)}",
|
|
||||||
'base64 < "$path" | tr -d "\\n"',
|
|
||||||
f"printf %s {_shquote(_TRANSFER_END)}",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _python_stdin_command(source: str, *, args: list[str]) -> str:
|
|
||||||
quoted_args = " ".join(shlex.quote(value) for value in args)
|
|
||||||
return f"python3 - {quoted_args} <<'PY'\n{source.strip()}\nPY"
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_workspace_payload(output: str) -> dict[str, object]:
|
|
||||||
begin = output.find(_WORKSPACE_PAYLOAD_BEGIN)
|
|
||||||
end = output.find(_WORKSPACE_PAYLOAD_END, begin + len(_WORKSPACE_PAYLOAD_BEGIN)) if begin >= 0 else -1
|
|
||||||
if begin < 0 or end < 0:
|
|
||||||
raise WorkspaceUnavailableError("workspace command returned no framed payload")
|
|
||||||
encoded = "".join(output[begin + len(_WORKSPACE_PAYLOAD_BEGIN) : end].split())
|
|
||||||
try:
|
|
||||||
value = json.loads(base64.b64decode(encoded, validate=True).decode("utf-8"))
|
|
||||||
except (binascii.Error, UnicodeDecodeError, ValueError) as exc:
|
|
||||||
raise WorkspaceUnavailableError("workspace command returned an invalid framed payload") from exc
|
|
||||||
if not isinstance(value, dict):
|
|
||||||
raise WorkspaceUnavailableError("workspace command returned a non-object payload")
|
|
||||||
return cast(dict[str, object], value)
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_transfer_payload(output: str) -> str:
|
|
||||||
pattern = re.escape(_TRANSFER_BEGIN) + r"(.*?)" + re.escape(_TRANSFER_END)
|
|
||||||
match = re.search(pattern, output, re.DOTALL)
|
|
||||||
if match is None:
|
|
||||||
raise ShellFileTransferError("Transfer payload markers were missing from shell output.")
|
|
||||||
return "".join(match.group(1).split())
|
|
||||||
|
|
||||||
|
|
||||||
def _output_tail(output: str, *, limit: int = 256) -> str:
|
|
||||||
return output[-limit:]
|
|
||||||
|
|
||||||
|
|
||||||
def _remaining_timeout(deadline: float) -> float:
|
|
||||||
remaining = deadline - time.monotonic()
|
|
||||||
if remaining <= 0.0:
|
|
||||||
raise ShellProviderError("Shellctl command timed out before completion.", code="timeout")
|
|
||||||
return remaining
|
|
||||||
|
|
||||||
|
|
||||||
def _lease_env(env: dict[str, str] | None, *, home_dir: str | None) -> dict[str, str] | None:
|
def _lease_env(env: dict[str, str] | None, *, home_dir: str | None) -> dict[str, str] | None:
|
||||||
if home_dir is None:
|
if home_dir is None:
|
||||||
return env
|
return env
|
||||||
@@ -699,15 +286,9 @@ def _resolve_lease_cwd(
|
|||||||
return candidate
|
return candidate
|
||||||
|
|
||||||
|
|
||||||
def _shquote(value: str) -> str:
|
|
||||||
return "'" + value.replace("'", "'\\''") + "'"
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"ShellFileTransferError",
|
|
||||||
"ShellctlClientFactory",
|
"ShellctlClientFactory",
|
||||||
"ShellctlClientProtocol",
|
"ShellctlClientProtocol",
|
||||||
"ShellctlCommands",
|
"ShellctlCommands",
|
||||||
"ShellctlFileTransfer",
|
|
||||||
"create_default_shellctl_client_factory",
|
"create_default_shellctl_client_factory",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ class DifyApiAgentStubFileRequestHandler:
|
|||||||
"""Call Dify API inner file request endpoints on behalf of the sandbox.
|
"""Call Dify API inner file request endpoints on behalf of the sandbox.
|
||||||
|
|
||||||
The upload path calls ``/inner/api/agent/files/upload-request`` and injects the
|
The upload path calls ``/inner/api/agent/files/upload-request`` and injects the
|
||||||
authenticated execution context's ``tenant_id``, ``user_id``, and optional
|
authenticated execution context's ``tenant_id``, ``user_id``, ``user_from``, and optional
|
||||||
``conversation_id`` along with the requested filename and mimetype. The download path calls
|
``conversation_id`` along with the requested filename and mimetype. The download path calls
|
||||||
``/inner/api/agent/files/download-request`` and injects ``tenant_id``,
|
``/inner/api/agent/files/download-request`` and injects ``tenant_id``,
|
||||||
``user_id``, ``user_from``, and ``invoke_from`` plus the validated public
|
``user_id``, ``user_from``, and ``invoke_from`` plus the validated public
|
||||||
@@ -143,6 +143,7 @@ class DifyApiAgentStubFileRequestHandler:
|
|||||||
payload = {
|
payload = {
|
||||||
"tenant_id": execution_context.tenant_id,
|
"tenant_id": execution_context.tenant_id,
|
||||||
"user_id": execution_context.user_id,
|
"user_id": execution_context.user_id,
|
||||||
|
"user_from": execution_context.user_from,
|
||||||
"filename": request.filename,
|
"filename": request.filename,
|
||||||
"mimetype": request.mimetype,
|
"mimetype": request.mimetype,
|
||||||
"conversation_id": execution_context.conversation_id,
|
"conversation_id": execution_context.conversation_id,
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ from pydantic_ai.messages import FunctionToolResultEvent
|
|||||||
from dify_agent.protocol import (
|
from dify_agent.protocol import (
|
||||||
CancelRunRequest,
|
CancelRunRequest,
|
||||||
CancelRunResponse,
|
CancelRunResponse,
|
||||||
|
BindingFileDownloadRequest,
|
||||||
|
BindingFileDownloadResponse,
|
||||||
|
BindingFileListRequest,
|
||||||
|
BindingFileListResponse,
|
||||||
|
BindingFileReadRequest,
|
||||||
|
BindingFileReadResponse,
|
||||||
CreateRunRequest,
|
CreateRunRequest,
|
||||||
CreateRunResponse,
|
CreateRunResponse,
|
||||||
CreateExecutionBindingRequest,
|
CreateExecutionBindingRequest,
|
||||||
@@ -40,17 +46,12 @@ from dify_agent.protocol import (
|
|||||||
RunEvent,
|
RunEvent,
|
||||||
RunEventsResponse,
|
RunEventsResponse,
|
||||||
RunStatusResponse,
|
RunStatusResponse,
|
||||||
WorkspaceListRequest,
|
|
||||||
WorkspaceListResponse,
|
|
||||||
WorkspaceReadRequest,
|
|
||||||
WorkspaceReadResponse,
|
|
||||||
WorkspaceUploadRequest,
|
|
||||||
WorkspaceUploadResponse,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
_ResponseModelT = TypeVar("_ResponseModelT", bound=BaseModel)
|
_ResponseModelT = TypeVar("_ResponseModelT", bound=BaseModel)
|
||||||
_TERMINAL_EVENT_TYPES = {"run_succeeded", "run_failed", "run_cancelled"}
|
_TERMINAL_EVENT_TYPES = {"run_succeeded", "run_failed", "run_cancelled"}
|
||||||
_TERMINAL_RUN_STATUSES = {"succeeded", "failed", "cancelled"}
|
_TERMINAL_RUN_STATUSES = {"succeeded", "failed", "cancelled"}
|
||||||
|
_BINDING_FILE_DOWNLOAD_TIMEOUT_SECONDS = 90.0
|
||||||
_function_tool_result_payload_key_cache: str | None = None
|
_function_tool_result_payload_key_cache: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@@ -260,7 +261,7 @@ class Client:
|
|||||||
headers, timeout settings, optional external HTTPX clients, and lazy-owned
|
headers, timeout settings, optional external HTTPX clients, and lazy-owned
|
||||||
clients for whichever sync/async side is used. It is the shared transport
|
clients for whichever sync/async side is used. It is the shared transport
|
||||||
boundary for both run-management endpoints (create/status/events/cancel) and
|
boundary for both run-management endpoints (create/status/events/cancel) and
|
||||||
sandbox-file endpoints (list/read/upload). External clients are never closed
|
Binding-file endpoints (list/read/download). External clients are never closed
|
||||||
by this wrapper. Owned sync clients close via ``close_sync`` or the sync
|
by this wrapper. Owned sync clients close via ``close_sync`` or the sync
|
||||||
context manager; owned async clients close via ``aclose`` or the async
|
context manager; owned async clients close via ``aclose`` or the async
|
||||||
context manager.
|
context manager.
|
||||||
@@ -474,43 +475,53 @@ class Client:
|
|||||||
raise DifyAgentClientError(f"get_events_sync request failed: {exc}") from exc
|
raise DifyAgentClientError(f"get_events_sync request failed: {exc}") from exc
|
||||||
return _parse_model_response(response, RunEventsResponse)
|
return _parse_model_response(response, RunEventsResponse)
|
||||||
|
|
||||||
async def list_workspace_files(self, backend_binding_ref: str, path: str) -> WorkspaceListResponse:
|
async def list_binding_files(self, backend_binding_ref: str, path: str) -> BindingFileListResponse:
|
||||||
request_model = WorkspaceListRequest(backend_binding_ref=backend_binding_ref, path=path)
|
request_model = BindingFileListRequest(backend_binding_ref=backend_binding_ref, path=path)
|
||||||
response = await self._post_async_json("list_workspace_files", "/workspace/files/list", request_model)
|
response = await self._post_async_json("list_binding_files", "/execution-bindings/files/list", request_model)
|
||||||
return _parse_model_response(response, WorkspaceListResponse)
|
return _parse_model_response(response, BindingFileListResponse)
|
||||||
|
|
||||||
def list_workspace_files_sync(self, backend_binding_ref: str, path: str) -> WorkspaceListResponse:
|
def list_binding_files_sync(self, backend_binding_ref: str, path: str) -> BindingFileListResponse:
|
||||||
request_model = WorkspaceListRequest(backend_binding_ref=backend_binding_ref, path=path)
|
request_model = BindingFileListRequest(backend_binding_ref=backend_binding_ref, path=path)
|
||||||
response = self._post_sync_json("list_workspace_files_sync", "/workspace/files/list", request_model)
|
response = self._post_sync_json("list_binding_files_sync", "/execution-bindings/files/list", request_model)
|
||||||
return _parse_model_response(response, WorkspaceListResponse)
|
return _parse_model_response(response, BindingFileListResponse)
|
||||||
|
|
||||||
async def read_workspace_file(
|
async def read_binding_file(
|
||||||
self,
|
self,
|
||||||
backend_binding_ref: str,
|
backend_binding_ref: str,
|
||||||
path: str,
|
path: str,
|
||||||
max_bytes: int = 262144,
|
max_bytes: int = 262144,
|
||||||
) -> WorkspaceReadResponse:
|
) -> BindingFileReadResponse:
|
||||||
request_model = WorkspaceReadRequest(backend_binding_ref=backend_binding_ref, path=path, max_bytes=max_bytes)
|
request_model = BindingFileReadRequest(backend_binding_ref=backend_binding_ref, path=path, max_bytes=max_bytes)
|
||||||
response = await self._post_async_json("read_workspace_file", "/workspace/files/read", request_model)
|
response = await self._post_async_json("read_binding_file", "/execution-bindings/files/read", request_model)
|
||||||
return _parse_model_response(response, WorkspaceReadResponse)
|
return _parse_model_response(response, BindingFileReadResponse)
|
||||||
|
|
||||||
def read_workspace_file_sync(
|
def read_binding_file_sync(
|
||||||
self,
|
self,
|
||||||
backend_binding_ref: str,
|
backend_binding_ref: str,
|
||||||
path: str,
|
path: str,
|
||||||
max_bytes: int = 262144,
|
max_bytes: int = 262144,
|
||||||
) -> WorkspaceReadResponse:
|
) -> BindingFileReadResponse:
|
||||||
request_model = WorkspaceReadRequest(backend_binding_ref=backend_binding_ref, path=path, max_bytes=max_bytes)
|
request_model = BindingFileReadRequest(backend_binding_ref=backend_binding_ref, path=path, max_bytes=max_bytes)
|
||||||
response = self._post_sync_json("read_workspace_file_sync", "/workspace/files/read", request_model)
|
response = self._post_sync_json("read_binding_file_sync", "/execution-bindings/files/read", request_model)
|
||||||
return _parse_model_response(response, WorkspaceReadResponse)
|
return _parse_model_response(response, BindingFileReadResponse)
|
||||||
|
|
||||||
async def upload_workspace_file(self, request: WorkspaceUploadRequest) -> WorkspaceUploadResponse:
|
async def download_binding_file(self, request: BindingFileDownloadRequest) -> BindingFileDownloadResponse:
|
||||||
response = await self._post_async_json("upload_workspace_file", "/workspace/files/upload", request)
|
response = await self._post_async_json(
|
||||||
return _parse_model_response(response, WorkspaceUploadResponse)
|
"download_binding_file",
|
||||||
|
"/execution-bindings/files/download",
|
||||||
|
request,
|
||||||
|
timeout=_BINDING_FILE_DOWNLOAD_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
return _parse_model_response(response, BindingFileDownloadResponse)
|
||||||
|
|
||||||
def upload_workspace_file_sync(self, request: WorkspaceUploadRequest) -> WorkspaceUploadResponse:
|
def download_binding_file_sync(self, request: BindingFileDownloadRequest) -> BindingFileDownloadResponse:
|
||||||
response = self._post_sync_json("upload_workspace_file_sync", "/workspace/files/upload", request)
|
response = self._post_sync_json(
|
||||||
return _parse_model_response(response, WorkspaceUploadResponse)
|
"download_binding_file_sync",
|
||||||
|
"/execution-bindings/files/download",
|
||||||
|
request,
|
||||||
|
timeout=_BINDING_FILE_DOWNLOAD_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
return _parse_model_response(response, BindingFileDownloadResponse)
|
||||||
|
|
||||||
async def create_execution_binding(self, request: CreateExecutionBindingRequest) -> CreateExecutionBindingResponse:
|
async def create_execution_binding(self, request: CreateExecutionBindingRequest) -> CreateExecutionBindingResponse:
|
||||||
response = await self._post_async_json("create_execution_binding", "/execution-bindings", request)
|
response = await self._post_async_json("create_execution_binding", "/execution-bindings", request)
|
||||||
@@ -862,26 +873,40 @@ class Client:
|
|||||||
headers.update(extra)
|
headers.update(extra)
|
||||||
return headers
|
return headers
|
||||||
|
|
||||||
async def _post_async_json(self, operation: str, path: str, request_model: BaseModel) -> httpx.Response:
|
async def _post_async_json(
|
||||||
|
self,
|
||||||
|
operation: str,
|
||||||
|
path: str,
|
||||||
|
request_model: BaseModel,
|
||||||
|
*,
|
||||||
|
timeout: float | httpx.Timeout | None = None,
|
||||||
|
) -> httpx.Response:
|
||||||
try:
|
try:
|
||||||
return await self._get_async_http_client().post(
|
return await self._get_async_http_client().post(
|
||||||
self._url(path),
|
self._url(path),
|
||||||
content=request_model.model_dump_json(),
|
content=request_model.model_dump_json(),
|
||||||
headers=self._merged_headers({"Content-Type": "application/json"}),
|
headers=self._merged_headers({"Content-Type": "application/json"}),
|
||||||
timeout=self._timeout,
|
timeout=self._timeout if timeout is None else timeout,
|
||||||
)
|
)
|
||||||
except httpx.TimeoutException as exc:
|
except httpx.TimeoutException as exc:
|
||||||
raise DifyAgentTimeoutError(f"{operation} timed out") from exc
|
raise DifyAgentTimeoutError(f"{operation} timed out") from exc
|
||||||
except httpx.RequestError as exc:
|
except httpx.RequestError as exc:
|
||||||
raise DifyAgentClientError(f"{operation} request failed: {exc}") from exc
|
raise DifyAgentClientError(f"{operation} request failed: {exc}") from exc
|
||||||
|
|
||||||
def _post_sync_json(self, operation: str, path: str, request_model: BaseModel) -> httpx.Response:
|
def _post_sync_json(
|
||||||
|
self,
|
||||||
|
operation: str,
|
||||||
|
path: str,
|
||||||
|
request_model: BaseModel,
|
||||||
|
*,
|
||||||
|
timeout: float | httpx.Timeout | None = None,
|
||||||
|
) -> httpx.Response:
|
||||||
try:
|
try:
|
||||||
return self._get_sync_http_client().post(
|
return self._get_sync_http_client().post(
|
||||||
self._url(path),
|
self._url(path),
|
||||||
content=request_model.model_dump_json(),
|
content=request_model.model_dump_json(),
|
||||||
headers=self._merged_headers({"Content-Type": "application/json"}),
|
headers=self._merged_headers({"Content-Type": "application/json"}),
|
||||||
timeout=self._timeout,
|
timeout=self._timeout if timeout is None else timeout,
|
||||||
)
|
)
|
||||||
except httpx.TimeoutException as exc:
|
except httpx.TimeoutException as exc:
|
||||||
raise DifyAgentTimeoutError(f"{operation} timed out") from exc
|
raise DifyAgentTimeoutError(f"{operation} timed out") from exc
|
||||||
|
|||||||
@@ -21,5 +21,5 @@
|
|||||||
"drive push": "Upload one local file or directory into the agent drive.\n\nUsage:\n dify-agent drive push LOCAL_PATH REMOTE_PATH [flags]\n\nFlags:\n -h, --help help for push\n --json Accepted for consistency; drive push output is already emitted as JSON.\n --kind string Directory upload kind: skill or dir.",
|
"drive push": "Upload one local file or directory into the agent drive.\n\nUsage:\n dify-agent drive push LOCAL_PATH REMOTE_PATH [flags]\n\nFlags:\n -h, --help help for push\n --json Accepted for consistency; drive push output is already emitted as JSON.\n --kind string Directory upload kind: skill or dir.",
|
||||||
"file": "Upload or download workflow files through the Agent Stub.\n\nUsage:\n dify-agent file [command]\n\nAvailable Commands:\n download Download one workflow file mapping into the local sandbox directory.\n upload Upload one sandbox-local file as a ToolFile output reference.\n\nFlags:\n -h, --help help for file\n\nUse \"dify-agent file [command] --help\" for more information about a command.",
|
"file": "Upload or download workflow files through the Agent Stub.\n\nUsage:\n dify-agent file [command]\n\nAvailable Commands:\n download Download one workflow file mapping into the local sandbox directory.\n upload Upload one sandbox-local file as a ToolFile output reference.\n\nFlags:\n -h, --help help for file\n\nUse \"dify-agent file [command] --help\" for more information about a command.",
|
||||||
"file download": "Download one workflow file mapping into the local sandbox directory.\n\nUsage:\n dify-agent file download TRANSFER_METHOD REFERENCE_OR_URL [flags]\n\nFlags:\n -h, --help help for download\n --to string Local directory for the downloaded file.",
|
"file download": "Download one workflow file mapping into the local sandbox directory.\n\nUsage:\n dify-agent file download TRANSFER_METHOD REFERENCE_OR_URL [flags]\n\nFlags:\n -h, --help help for download\n --to string Local directory for the downloaded file.",
|
||||||
"file upload": "Upload one sandbox-local file as a ToolFile output reference.\n\nUsage:\n dify-agent file upload PATH [flags]\n\nFlags:\n -h, --help help for upload"
|
"file upload": "Upload one sandbox-local file as a ToolFile output reference.\n\nUsage:\n dify-agent file upload PATH [flags]\n\nFlags:\n -h, --help help for upload\n --no-download-link Skip creating a public download link after upload."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,8 @@ from collections.abc import Sequence
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import time
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import ClassVar, Literal, NotRequired, Protocol, TypedDict, runtime_checkable
|
from typing import ClassVar, NotRequired, Protocol, TypedDict, runtime_checkable
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, field_validator, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, field_validator, model_validator
|
||||||
from pydantic_ai import Tool
|
from pydantic_ai import Tool
|
||||||
@@ -35,6 +34,7 @@ from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
|||||||
from dify_agent.layers.runtime.layer import DifyRuntimeLayer
|
from dify_agent.layers.runtime.layer import DifyRuntimeLayer
|
||||||
from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
|
from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
|
||||||
from dify_agent.layers.shell.output_text import normalized_output_text, utf8_prefix, utf8_suffix
|
from dify_agent.layers.shell.output_text import normalized_output_text, utf8_prefix, utf8_suffix
|
||||||
|
from dify_agent.runtime.command_runner import execute_complete_with_commands
|
||||||
from dify_agent.runtime_backend import RuntimeLease
|
from dify_agent.runtime_backend import RuntimeLease
|
||||||
|
|
||||||
|
|
||||||
@@ -545,77 +545,6 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
async def execute_complete_with_commands(
|
|
||||||
commands: ShellCommandProtocol,
|
|
||||||
script: str,
|
|
||||||
*,
|
|
||||||
cwd: str | None,
|
|
||||||
env: dict[str, str] | None,
|
|
||||||
timeout: float,
|
|
||||||
max_output_bytes: int,
|
|
||||||
) -> CompleteShellCommandResult:
|
|
||||||
deadline = time.monotonic() + timeout
|
|
||||||
job_id: str | None = None
|
|
||||||
result: ShellCommandResult | None = None
|
|
||||||
output_parts: list[str] = []
|
|
||||||
captured_bytes = 0
|
|
||||||
incomplete_reason: Literal["output_limit", "timeout"] | None = None
|
|
||||||
try:
|
|
||||||
result = await commands.run(script, cwd=cwd, env=env, timeout=_remaining_time(deadline))
|
|
||||||
job_id = result.job_id
|
|
||||||
while True:
|
|
||||||
remaining_bytes = max(max_output_bytes - captured_bytes, 0)
|
|
||||||
limited_output = utf8_prefix(result.output, remaining_bytes)
|
|
||||||
output_parts.append(limited_output)
|
|
||||||
captured_bytes += len(limited_output.encode("utf-8"))
|
|
||||||
if limited_output != result.output:
|
|
||||||
incomplete_reason = "output_limit"
|
|
||||||
break
|
|
||||||
if captured_bytes >= max_output_bytes and (result.truncated or not result.done):
|
|
||||||
incomplete_reason = "output_limit"
|
|
||||||
break
|
|
||||||
if result.truncated:
|
|
||||||
result = await commands.read_output(result.job_id, offset=result.offset)
|
|
||||||
continue
|
|
||||||
if result.done:
|
|
||||||
break
|
|
||||||
remaining_time = _remaining_time(deadline)
|
|
||||||
if remaining_time <= 0.0:
|
|
||||||
incomplete_reason = "timeout"
|
|
||||||
break
|
|
||||||
result = await commands.wait(result.job_id, offset=result.offset, timeout=remaining_time)
|
|
||||||
|
|
||||||
assert result is not None
|
|
||||||
final_status = result.status
|
|
||||||
final_done = result.done
|
|
||||||
final_exit_code = result.exit_code
|
|
||||||
final_offset = result.offset
|
|
||||||
final_output_path = result.output_path
|
|
||||||
if incomplete_reason is not None and not result.done:
|
|
||||||
terminal_status = await commands.interrupt(result.job_id, grace_seconds=DEFAULT_TERMINATE_GRACE_SECONDS)
|
|
||||||
final_status = terminal_status.status
|
|
||||||
final_done = terminal_status.done
|
|
||||||
final_exit_code = terminal_status.exit_code
|
|
||||||
final_offset = terminal_status.offset
|
|
||||||
return CompleteShellCommandResult(
|
|
||||||
job_id=result.job_id,
|
|
||||||
status=final_status,
|
|
||||||
done=final_done,
|
|
||||||
exit_code=final_exit_code,
|
|
||||||
output="".join(output_parts),
|
|
||||||
output_complete=incomplete_reason is None,
|
|
||||||
incomplete_reason=incomplete_reason,
|
|
||||||
offset=final_offset,
|
|
||||||
output_path=final_output_path,
|
|
||||||
)
|
|
||||||
finally:
|
|
||||||
if job_id is not None:
|
|
||||||
try:
|
|
||||||
await commands.delete(job_id, force=True)
|
|
||||||
except RuntimeError as exc:
|
|
||||||
logger.warning("Failed to delete transient shell job %s: %s", job_id, exc)
|
|
||||||
|
|
||||||
|
|
||||||
async def render_prompt_observation_from_result(
|
async def render_prompt_observation_from_result(
|
||||||
commands: ShellCommandProtocol,
|
commands: ShellCommandProtocol,
|
||||||
result: ShellCommandResult,
|
result: ShellCommandResult,
|
||||||
@@ -765,10 +694,6 @@ def _tagged_shell_observation(metadata: dict[str, object], output: str) -> str:
|
|||||||
return f"<metadata>\n{compact_metadata}\n</metadata>\n\n<output>\n{output}\n</output>"
|
return f"<metadata>\n{compact_metadata}\n</metadata>\n\n<output>\n{output}\n</output>"
|
||||||
|
|
||||||
|
|
||||||
def _remaining_time(deadline: float) -> float:
|
|
||||||
return max(0.0, deadline - time.monotonic())
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CompleteRemoteCommandResult",
|
"CompleteRemoteCommandResult",
|
||||||
"DifyShellLayer",
|
"DifyShellLayer",
|
||||||
@@ -776,6 +701,5 @@ __all__ = [
|
|||||||
"DifyShellRuntimeState",
|
"DifyShellRuntimeState",
|
||||||
"DEFAULT_TERMINATE_GRACE_SECONDS",
|
"DEFAULT_TERMINATE_GRACE_SECONDS",
|
||||||
"DEFAULT_TIMEOUT_SECONDS",
|
"DEFAULT_TIMEOUT_SECONDS",
|
||||||
"execute_complete_with_commands",
|
|
||||||
"render_prompt_observation_from_result",
|
"render_prompt_observation_from_result",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -43,24 +43,30 @@ from .execution_binding import (
|
|||||||
CreateExecutionBindingResponse,
|
CreateExecutionBindingResponse,
|
||||||
DestroyExecutionBindingRequest,
|
DestroyExecutionBindingRequest,
|
||||||
)
|
)
|
||||||
|
from .binding_file import (
|
||||||
|
BindingFileDownloadRequest,
|
||||||
|
BindingFileDownloadResponse,
|
||||||
|
BindingFileEntry,
|
||||||
|
BindingFileListRequest,
|
||||||
|
BindingFileListResponse,
|
||||||
|
BindingFileReadRequest,
|
||||||
|
BindingFileReadResponse,
|
||||||
|
)
|
||||||
from .home_snapshot import (
|
from .home_snapshot import (
|
||||||
CreateHomeSnapshotFromBindingRequest,
|
CreateHomeSnapshotFromBindingRequest,
|
||||||
DeleteHomeSnapshotRequest,
|
DeleteHomeSnapshotRequest,
|
||||||
HomeSnapshotResponse,
|
HomeSnapshotResponse,
|
||||||
)
|
)
|
||||||
from .workspace import (
|
|
||||||
WorkspaceFileEntry,
|
|
||||||
WorkspaceListRequest,
|
|
||||||
WorkspaceListResponse,
|
|
||||||
WorkspaceReadRequest,
|
|
||||||
WorkspaceReadResponse,
|
|
||||||
WorkspaceUploadRequest,
|
|
||||||
WorkspaceUploadResponse,
|
|
||||||
WorkspaceUploadedFile,
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BaseRunEvent",
|
"BaseRunEvent",
|
||||||
|
"BindingFileDownloadRequest",
|
||||||
|
"BindingFileDownloadResponse",
|
||||||
|
"BindingFileEntry",
|
||||||
|
"BindingFileListRequest",
|
||||||
|
"BindingFileListResponse",
|
||||||
|
"BindingFileReadRequest",
|
||||||
|
"BindingFileReadResponse",
|
||||||
"AgentRunUsage",
|
"AgentRunUsage",
|
||||||
"CancelRunRequest",
|
"CancelRunRequest",
|
||||||
"CancelRunResponse",
|
"CancelRunResponse",
|
||||||
@@ -96,14 +102,6 @@ __all__ = [
|
|||||||
"RunStatusResponse",
|
"RunStatusResponse",
|
||||||
"RunSucceededEvent",
|
"RunSucceededEvent",
|
||||||
"RunSucceededEventData",
|
"RunSucceededEventData",
|
||||||
"WorkspaceFileEntry",
|
|
||||||
"WorkspaceListRequest",
|
|
||||||
"WorkspaceListResponse",
|
|
||||||
"WorkspaceReadRequest",
|
|
||||||
"WorkspaceReadResponse",
|
|
||||||
"WorkspaceUploadRequest",
|
|
||||||
"WorkspaceUploadResponse",
|
|
||||||
"WorkspaceUploadedFile",
|
|
||||||
"normalize_composition",
|
"normalize_composition",
|
||||||
"utc_now",
|
"utc_now",
|
||||||
]
|
]
|
||||||
|
|||||||
+26
-30
@@ -1,4 +1,4 @@
|
|||||||
"""Private Workspace file DTOs resolved through an Execution Binding ref."""
|
"""Private Binding file DTOs resolved through an Execution Binding ref."""
|
||||||
|
|
||||||
from typing import ClassVar, Literal
|
from typing import ClassVar, Literal
|
||||||
|
|
||||||
@@ -6,8 +6,10 @@ from pydantic import BaseModel, ConfigDict, Field
|
|||||||
|
|
||||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||||
|
|
||||||
|
_BINDING_FILE_PREVIEW_MAX_BYTES = 262144
|
||||||
|
|
||||||
class WorkspaceFileEntry(BaseModel):
|
|
||||||
|
class BindingFileEntry(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
type: Literal["file", "dir", "symlink", "other"]
|
type: Literal["file", "dir", "symlink", "other"]
|
||||||
size: int | None = None
|
size: int | None = None
|
||||||
@@ -16,30 +18,34 @@ class WorkspaceFileEntry(BaseModel):
|
|||||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceListRequest(BaseModel):
|
class BindingFileListRequest(BaseModel):
|
||||||
backend_binding_ref: str = Field(min_length=1)
|
backend_binding_ref: str = Field(min_length=1)
|
||||||
path: str = "."
|
path: str = "."
|
||||||
|
|
||||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceListResponse(BaseModel):
|
class BindingFileListResponse(BaseModel):
|
||||||
path: str
|
path: str
|
||||||
entries: list[WorkspaceFileEntry]
|
entries: list[BindingFileEntry]
|
||||||
truncated: bool
|
truncated: bool
|
||||||
|
|
||||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceReadRequest(BaseModel):
|
class BindingFileReadRequest(BaseModel):
|
||||||
backend_binding_ref: str = Field(min_length=1)
|
backend_binding_ref: str = Field(min_length=1)
|
||||||
path: str
|
path: str = Field(min_length=1)
|
||||||
max_bytes: int = 262144
|
max_bytes: int = Field(
|
||||||
|
default=_BINDING_FILE_PREVIEW_MAX_BYTES,
|
||||||
|
ge=1,
|
||||||
|
le=_BINDING_FILE_PREVIEW_MAX_BYTES,
|
||||||
|
)
|
||||||
|
|
||||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceReadResponse(BaseModel):
|
class BindingFileReadResponse(BaseModel):
|
||||||
path: str
|
path: str
|
||||||
size: int | None = None
|
size: int | None = None
|
||||||
truncated: bool
|
truncated: bool
|
||||||
@@ -49,36 +55,26 @@ class WorkspaceReadResponse(BaseModel):
|
|||||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceUploadedFile(BaseModel):
|
class BindingFileDownloadRequest(BaseModel):
|
||||||
transfer_method: Literal["tool_file"] = "tool_file"
|
|
||||||
reference: str
|
|
||||||
download_url: str
|
|
||||||
|
|
||||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceUploadRequest(BaseModel):
|
|
||||||
backend_binding_ref: str = Field(min_length=1)
|
backend_binding_ref: str = Field(min_length=1)
|
||||||
path: str
|
path: str = Field(min_length=1)
|
||||||
execution_context: DifyExecutionContextLayerConfig
|
execution_context: DifyExecutionContextLayerConfig
|
||||||
|
|
||||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceUploadResponse(BaseModel):
|
class BindingFileDownloadResponse(BaseModel):
|
||||||
path: str
|
reference: str
|
||||||
file: WorkspaceUploadedFile
|
|
||||||
|
|
||||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"WorkspaceFileEntry",
|
"BindingFileDownloadRequest",
|
||||||
"WorkspaceListRequest",
|
"BindingFileDownloadResponse",
|
||||||
"WorkspaceListResponse",
|
"BindingFileEntry",
|
||||||
"WorkspaceReadRequest",
|
"BindingFileListRequest",
|
||||||
"WorkspaceReadResponse",
|
"BindingFileListResponse",
|
||||||
"WorkspaceUploadRequest",
|
"BindingFileReadRequest",
|
||||||
"WorkspaceUploadResponse",
|
"BindingFileReadResponse",
|
||||||
"WorkspaceUploadedFile",
|
|
||||||
]
|
]
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Bounded execution of one transient command through a RuntimeLease."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from dify_agent.adapters.shell.protocols import (
|
||||||
|
CompleteShellCommandResult,
|
||||||
|
ShellCommandProtocol,
|
||||||
|
ShellCommandResult,
|
||||||
|
)
|
||||||
|
from dify_agent.layers.shell.output_text import utf8_prefix
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_TERMINATE_GRACE_SECONDS = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
async def execute_complete_with_commands(
|
||||||
|
commands: ShellCommandProtocol,
|
||||||
|
script: str,
|
||||||
|
*,
|
||||||
|
cwd: str | None,
|
||||||
|
env: dict[str, str] | None,
|
||||||
|
timeout: float,
|
||||||
|
max_output_bytes: int,
|
||||||
|
) -> CompleteShellCommandResult:
|
||||||
|
"""Run a command to completion with bounded output and deterministic cleanup."""
|
||||||
|
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
job_id: str | None = None
|
||||||
|
result: ShellCommandResult | None = None
|
||||||
|
output_parts: list[str] = []
|
||||||
|
captured_bytes = 0
|
||||||
|
incomplete_reason: Literal["output_limit", "timeout"] | None = None
|
||||||
|
try:
|
||||||
|
result = await commands.run(script, cwd=cwd, env=env, timeout=_remaining_time(deadline))
|
||||||
|
job_id = result.job_id
|
||||||
|
while True:
|
||||||
|
remaining_bytes = max(max_output_bytes - captured_bytes, 0)
|
||||||
|
limited_output = utf8_prefix(result.output, remaining_bytes)
|
||||||
|
output_parts.append(limited_output)
|
||||||
|
captured_bytes += len(limited_output.encode("utf-8"))
|
||||||
|
if limited_output != result.output:
|
||||||
|
incomplete_reason = "output_limit"
|
||||||
|
break
|
||||||
|
if captured_bytes >= max_output_bytes and (result.truncated or not result.done):
|
||||||
|
incomplete_reason = "output_limit"
|
||||||
|
break
|
||||||
|
if result.truncated:
|
||||||
|
result = await commands.read_output(result.job_id, offset=result.offset)
|
||||||
|
continue
|
||||||
|
if result.done:
|
||||||
|
break
|
||||||
|
remaining_time = _remaining_time(deadline)
|
||||||
|
if remaining_time <= 0.0:
|
||||||
|
incomplete_reason = "timeout"
|
||||||
|
break
|
||||||
|
result = await commands.wait(result.job_id, offset=result.offset, timeout=remaining_time)
|
||||||
|
|
||||||
|
final_status = result.status
|
||||||
|
final_done = result.done
|
||||||
|
final_exit_code = result.exit_code
|
||||||
|
final_offset = result.offset
|
||||||
|
final_output_path = result.output_path
|
||||||
|
if incomplete_reason is not None and not result.done:
|
||||||
|
terminal_status = await commands.interrupt(result.job_id, grace_seconds=_TERMINATE_GRACE_SECONDS)
|
||||||
|
final_status = terminal_status.status
|
||||||
|
final_done = terminal_status.done
|
||||||
|
final_exit_code = terminal_status.exit_code
|
||||||
|
final_offset = terminal_status.offset
|
||||||
|
return CompleteShellCommandResult(
|
||||||
|
job_id=result.job_id,
|
||||||
|
status=final_status,
|
||||||
|
done=final_done,
|
||||||
|
exit_code=final_exit_code,
|
||||||
|
output="".join(output_parts),
|
||||||
|
output_complete=incomplete_reason is None,
|
||||||
|
incomplete_reason=incomplete_reason,
|
||||||
|
offset=final_offset,
|
||||||
|
output_path=final_output_path,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
if job_id is not None:
|
||||||
|
try:
|
||||||
|
await commands.delete(job_id, force=True)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
logger.warning("Failed to delete transient shell job %s: %s", job_id, exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _remaining_time(deadline: float) -> float:
|
||||||
|
return max(0.0, deadline - time.monotonic())
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["execute_complete_with_commands"]
|
||||||
@@ -9,8 +9,6 @@ from .errors import (
|
|||||||
HomeSnapshotNotFoundError,
|
HomeSnapshotNotFoundError,
|
||||||
RuntimeBackendError,
|
RuntimeBackendError,
|
||||||
SharedWorkspaceUnsupportedError,
|
SharedWorkspaceUnsupportedError,
|
||||||
WorkspaceFileTooLargeError,
|
|
||||||
WorkspacePathError,
|
|
||||||
WorkspacePreservationUnsupportedError,
|
WorkspacePreservationUnsupportedError,
|
||||||
WorkspaceUnavailableError,
|
WorkspaceUnavailableError,
|
||||||
)
|
)
|
||||||
@@ -19,16 +17,11 @@ from .protocols import (
|
|||||||
ExecutionBindingBackend,
|
ExecutionBindingBackend,
|
||||||
ExecutionBindingCreateSpec,
|
ExecutionBindingCreateSpec,
|
||||||
ExecutionBindingDestroySpec,
|
ExecutionBindingDestroySpec,
|
||||||
FileSystem,
|
|
||||||
HomeSnapshotBackend,
|
HomeSnapshotBackend,
|
||||||
HomeSnapshotCreateSpec,
|
HomeSnapshotCreateSpec,
|
||||||
RuntimeBackendProfile,
|
RuntimeBackendProfile,
|
||||||
RuntimeLayout,
|
RuntimeLayout,
|
||||||
RuntimeLease,
|
RuntimeLease,
|
||||||
WorkspaceFileContent,
|
|
||||||
WorkspaceFileEntry,
|
|
||||||
WorkspaceListResult,
|
|
||||||
WorkspaceReadResult,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
@@ -40,7 +33,6 @@ __all__ = [
|
|||||||
"ExecutionBindingBackend",
|
"ExecutionBindingBackend",
|
||||||
"ExecutionBindingCreateSpec",
|
"ExecutionBindingCreateSpec",
|
||||||
"ExecutionBindingDestroySpec",
|
"ExecutionBindingDestroySpec",
|
||||||
"FileSystem",
|
|
||||||
"HomeSnapshotBackend",
|
"HomeSnapshotBackend",
|
||||||
"HomeSnapshotCreateError",
|
"HomeSnapshotCreateError",
|
||||||
"HomeSnapshotCreateSpec",
|
"HomeSnapshotCreateSpec",
|
||||||
@@ -50,12 +42,6 @@ __all__ = [
|
|||||||
"RuntimeLayout",
|
"RuntimeLayout",
|
||||||
"RuntimeLease",
|
"RuntimeLease",
|
||||||
"SharedWorkspaceUnsupportedError",
|
"SharedWorkspaceUnsupportedError",
|
||||||
"WorkspaceFileContent",
|
|
||||||
"WorkspaceFileEntry",
|
|
||||||
"WorkspaceFileTooLargeError",
|
|
||||||
"WorkspaceListResult",
|
|
||||||
"WorkspacePathError",
|
|
||||||
"WorkspacePreservationUnsupportedError",
|
"WorkspacePreservationUnsupportedError",
|
||||||
"WorkspaceReadResult",
|
|
||||||
"WorkspaceUnavailableError",
|
"WorkspaceUnavailableError",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ from dify_agent.runtime_backend.protocols import (
|
|||||||
ExecutionBindingAllocation,
|
ExecutionBindingAllocation,
|
||||||
ExecutionBindingCreateSpec,
|
ExecutionBindingCreateSpec,
|
||||||
ExecutionBindingDestroySpec,
|
ExecutionBindingDestroySpec,
|
||||||
FileSystem,
|
|
||||||
HomeSnapshotCreateSpec,
|
HomeSnapshotCreateSpec,
|
||||||
RuntimeLayout,
|
RuntimeLayout,
|
||||||
RuntimeLease,
|
RuntimeLease,
|
||||||
@@ -358,10 +357,6 @@ class E2BRuntimeLease:
|
|||||||
def commands(self) -> ShellCommandProtocol:
|
def commands(self) -> ShellCommandProtocol:
|
||||||
return self.data_plane.commands
|
return self.data_plane.commands
|
||||||
|
|
||||||
@property
|
|
||||||
def files(self) -> FileSystem:
|
|
||||||
return self.data_plane.files
|
|
||||||
|
|
||||||
|
|
||||||
async def _wait_for_shellctl_ready(client: ShellctlClientProtocol) -> None:
|
async def _wait_for_shellctl_ready(client: ShellctlClientProtocol) -> None:
|
||||||
for attempt in range(_SHELLCTL_READY_MAX_ATTEMPTS):
|
for attempt in range(_SHELLCTL_READY_MAX_ATTEMPTS):
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ from dify_agent.runtime_backend.protocols import (
|
|||||||
ExecutionBindingAllocation,
|
ExecutionBindingAllocation,
|
||||||
ExecutionBindingCreateSpec,
|
ExecutionBindingCreateSpec,
|
||||||
ExecutionBindingDestroySpec,
|
ExecutionBindingDestroySpec,
|
||||||
FileSystem,
|
|
||||||
HomeSnapshotCreateSpec,
|
HomeSnapshotCreateSpec,
|
||||||
RuntimeLayout,
|
RuntimeLayout,
|
||||||
RuntimeLease,
|
RuntimeLease,
|
||||||
@@ -255,10 +254,6 @@ class EnterpriseRuntimeLease:
|
|||||||
def commands(self) -> ShellCommandProtocol:
|
def commands(self) -> ShellCommandProtocol:
|
||||||
return self.data_plane.commands
|
return self.data_plane.commands
|
||||||
|
|
||||||
@property
|
|
||||||
def files(self) -> FileSystem:
|
|
||||||
return self.data_plane.files
|
|
||||||
|
|
||||||
|
|
||||||
def _is_missing_sandbox(exc: ShellProviderError) -> bool:
|
def _is_missing_sandbox(exc: ShellProviderError) -> bool:
|
||||||
return exc.status_code == 404 or (exc.code or "").casefold() in {
|
return exc.status_code == 404 or (exc.code or "").casefold() in {
|
||||||
|
|||||||
@@ -41,22 +41,6 @@ class WorkspaceUnavailableError(RuntimeBackendError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class WorkspacePathError(RuntimeBackendError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceFileTooLargeError(RuntimeBackendError):
|
|
||||||
path: str
|
|
||||||
size: int
|
|
||||||
max_bytes: int
|
|
||||||
|
|
||||||
def __init__(self, *, path: str, size: int, max_bytes: int) -> None:
|
|
||||||
self.path = path
|
|
||||||
self.size = size
|
|
||||||
self.max_bytes = max_bytes
|
|
||||||
super().__init__(f"Workspace file {path!r} exceeds the {max_bytes}-byte ToolFile upload limit")
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BindingAcquireError",
|
"BindingAcquireError",
|
||||||
"BindingCreateError",
|
"BindingCreateError",
|
||||||
@@ -66,8 +50,6 @@ __all__ = [
|
|||||||
"HomeSnapshotNotFoundError",
|
"HomeSnapshotNotFoundError",
|
||||||
"RuntimeBackendError",
|
"RuntimeBackendError",
|
||||||
"SharedWorkspaceUnsupportedError",
|
"SharedWorkspaceUnsupportedError",
|
||||||
"WorkspaceFileTooLargeError",
|
|
||||||
"WorkspacePathError",
|
|
||||||
"WorkspacePreservationUnsupportedError",
|
"WorkspacePreservationUnsupportedError",
|
||||||
"WorkspaceUnavailableError",
|
"WorkspaceUnavailableError",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -28,51 +28,6 @@ class RuntimeLayout:
|
|||||||
workspace_dir: str
|
workspace_dir: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class WorkspaceFileEntry:
|
|
||||||
name: str
|
|
||||||
type: str
|
|
||||||
size: int | None
|
|
||||||
mtime: int | None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class WorkspaceListResult:
|
|
||||||
path: str
|
|
||||||
entries: tuple[WorkspaceFileEntry, ...]
|
|
||||||
truncated: bool
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class WorkspaceReadResult:
|
|
||||||
path: str
|
|
||||||
size: int
|
|
||||||
truncated: bool
|
|
||||||
binary: bool
|
|
||||||
text: str | None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class WorkspaceFileContent:
|
|
||||||
path: str
|
|
||||||
size: int
|
|
||||||
content: bytes
|
|
||||||
|
|
||||||
|
|
||||||
class FileSystem(Protocol):
|
|
||||||
"""File operations interpreted in the current RuntimeLease namespace."""
|
|
||||||
|
|
||||||
async def list_directory(self, *, path: str, limit: int) -> WorkspaceListResult: ...
|
|
||||||
|
|
||||||
async def read_file(self, *, path: str, max_bytes: int) -> WorkspaceReadResult: ...
|
|
||||||
|
|
||||||
async def read_bytes(self, *, path: str, max_bytes: int) -> WorkspaceFileContent: ...
|
|
||||||
|
|
||||||
async def upload(self, *, content: bytes, remote_path: str, cwd: str | None = None) -> None: ...
|
|
||||||
|
|
||||||
async def download(self, *, remote_path: str, cwd: str | None = None) -> bytes: ...
|
|
||||||
|
|
||||||
|
|
||||||
class RuntimeLease(Protocol):
|
class RuntimeLease(Protocol):
|
||||||
"""Invocation-local data-plane access to one persistent Binding."""
|
"""Invocation-local data-plane access to one persistent Binding."""
|
||||||
|
|
||||||
@@ -82,9 +37,6 @@ class RuntimeLease(Protocol):
|
|||||||
@property
|
@property
|
||||||
def commands(self) -> ShellCommandProtocol: ...
|
def commands(self) -> ShellCommandProtocol: ...
|
||||||
|
|
||||||
@property
|
|
||||||
def files(self) -> FileSystem: ...
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ExecutionBindingCreateSpec:
|
class ExecutionBindingCreateSpec:
|
||||||
@@ -203,14 +155,9 @@ __all__ = [
|
|||||||
"ExecutionBindingBackend",
|
"ExecutionBindingBackend",
|
||||||
"ExecutionBindingCreateSpec",
|
"ExecutionBindingCreateSpec",
|
||||||
"ExecutionBindingDestroySpec",
|
"ExecutionBindingDestroySpec",
|
||||||
"FileSystem",
|
|
||||||
"HomeSnapshotBackend",
|
"HomeSnapshotBackend",
|
||||||
"HomeSnapshotCreateSpec",
|
"HomeSnapshotCreateSpec",
|
||||||
"RuntimeBackendProfile",
|
"RuntimeBackendProfile",
|
||||||
"RuntimeLayout",
|
"RuntimeLayout",
|
||||||
"RuntimeLease",
|
"RuntimeLease",
|
||||||
"WorkspaceFileContent",
|
|
||||||
"WorkspaceFileEntry",
|
|
||||||
"WorkspaceListResult",
|
|
||||||
"WorkspaceReadResult",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,10 +11,9 @@ from dify_agent.adapters.shell.shellctl import (
|
|||||||
ShellctlClientFactory,
|
ShellctlClientFactory,
|
||||||
ShellctlClientProtocol,
|
ShellctlClientProtocol,
|
||||||
ShellctlCommands,
|
ShellctlCommands,
|
||||||
ShellctlFileTransfer,
|
|
||||||
create_default_shellctl_client_factory,
|
create_default_shellctl_client_factory,
|
||||||
)
|
)
|
||||||
from dify_agent.runtime_backend.protocols import FileSystem, RuntimeLayout
|
from dify_agent.runtime_backend.protocols import RuntimeLayout
|
||||||
|
|
||||||
_CONTROL_COMMAND_OUTPUT_LIMIT = 256 * 1024
|
_CONTROL_COMMAND_OUTPUT_LIMIT = 256 * 1024
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -32,7 +31,6 @@ class ShellctlRuntimeLease:
|
|||||||
layout: RuntimeLayout
|
layout: RuntimeLayout
|
||||||
client: ShellctlClientProtocol
|
client: ShellctlClientProtocol
|
||||||
commands: ShellCommandProtocol
|
commands: ShellCommandProtocol
|
||||||
files: FileSystem
|
|
||||||
owned_transport: AsyncCloseable | None = None
|
owned_transport: AsyncCloseable | None = None
|
||||||
_closed: bool = field(default=False, init=False)
|
_closed: bool = field(default=False, init=False)
|
||||||
|
|
||||||
@@ -77,11 +75,6 @@ def create_shellctl_lease(
|
|||||||
home_dir=layout.home_dir,
|
home_dir=layout.home_dir,
|
||||||
workspace_dir=layout.workspace_dir,
|
workspace_dir=layout.workspace_dir,
|
||||||
),
|
),
|
||||||
files=ShellctlFileTransfer(
|
|
||||||
client=client,
|
|
||||||
cwd=layout.workspace_dir,
|
|
||||||
home_dir=layout.home_dir,
|
|
||||||
),
|
|
||||||
owned_transport=owned_transport,
|
owned_transport=owned_transport,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ from dify_agent.server.observability import configure_server_observability
|
|||||||
from dify_agent.server.routes.runs import create_runs_router
|
from dify_agent.server.routes.runs import create_runs_router
|
||||||
from dify_agent.server.routes.execution_bindings import create_execution_bindings_router
|
from dify_agent.server.routes.execution_bindings import create_execution_bindings_router
|
||||||
from dify_agent.server.routes.home_snapshots import create_home_snapshots_router
|
from dify_agent.server.routes.home_snapshots import create_home_snapshots_router
|
||||||
from dify_agent.server.routes.workspace_files import create_workspace_files_router
|
from dify_agent.server.routes.binding_files import create_binding_files_router
|
||||||
from dify_agent.server.execution_bindings import ExecutionBindingService
|
from dify_agent.server.execution_bindings import ExecutionBindingService
|
||||||
from dify_agent.server.workspace_files import AgentStubWorkspaceFileUploader, WorkspaceFileService
|
from dify_agent.server.binding_files import BindingFileService
|
||||||
from dify_agent.server.home_snapshots import HomeSnapshotService
|
from dify_agent.server.home_snapshots import HomeSnapshotService
|
||||||
from dify_agent.server.settings import ServerSettings
|
from dify_agent.server.settings import ServerSettings
|
||||||
from dify_agent.storage.redis_run_store import RedisRunStore
|
from dify_agent.storage.redis_run_store import RedisRunStore
|
||||||
@@ -72,15 +72,11 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
|
|||||||
agent_stub_api_base_url=resolved_settings.agent_stub_api_base_url,
|
agent_stub_api_base_url=resolved_settings.agent_stub_api_base_url,
|
||||||
agent_stub_token_factory=agent_stub_token_factory,
|
agent_stub_token_factory=agent_stub_token_factory,
|
||||||
)
|
)
|
||||||
workspace_file_service = (
|
binding_file_service = (
|
||||||
WorkspaceFileService(
|
BindingFileService(
|
||||||
execution_bindings=runtime_backend_profile.execution_bindings,
|
execution_bindings=runtime_backend_profile.execution_bindings,
|
||||||
upload_max_bytes=resolved_settings.sandbox_file_upload_max_bytes,
|
agent_stub_api_base_url=resolved_settings.agent_stub_api_base_url,
|
||||||
file_uploader=(
|
agent_stub_token_factory=agent_stub_token_factory,
|
||||||
AgentStubWorkspaceFileUploader(file_request_handler=agent_stub_file_request_handler)
|
|
||||||
if agent_stub_file_request_handler is not None
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
if runtime_backend_profile is not None
|
if runtime_backend_profile is not None
|
||||||
else None
|
else None
|
||||||
@@ -145,7 +141,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
|
|||||||
)
|
)
|
||||||
app.include_router(create_execution_bindings_router(lambda: execution_binding_service))
|
app.include_router(create_execution_bindings_router(lambda: execution_binding_service))
|
||||||
app.include_router(create_home_snapshots_router(lambda: home_snapshot_service))
|
app.include_router(create_home_snapshots_router(lambda: home_snapshot_service))
|
||||||
app.include_router(create_workspace_files_router(lambda: workspace_file_service))
|
app.include_router(create_binding_files_router(lambda: binding_file_service))
|
||||||
app.include_router(
|
app.include_router(
|
||||||
create_agent_stub_router(
|
create_agent_stub_router(
|
||||||
token_codec=agent_stub_token_codec,
|
token_codec=agent_stub_token_codec,
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
"""Binding filesystem operations through an operation-scoped RuntimeLease."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import posixpath
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import ClassVar, Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, ValidationError
|
||||||
|
|
||||||
|
from dify_agent.adapters.shell.protocols import CompleteShellCommandResult, ShellProviderError
|
||||||
|
from dify_agent.agent_stub.protocol import is_canonical_dify_file_reference
|
||||||
|
from dify_agent.agent_stub.shell_env import ShellAgentStubTokenFactory, build_shell_agent_stub_env
|
||||||
|
from dify_agent.protocol import (
|
||||||
|
BindingFileDownloadRequest,
|
||||||
|
BindingFileDownloadResponse,
|
||||||
|
BindingFileListRequest,
|
||||||
|
BindingFileListResponse,
|
||||||
|
BindingFileReadRequest,
|
||||||
|
BindingFileReadResponse,
|
||||||
|
)
|
||||||
|
from dify_agent.runtime.command_runner import execute_complete_with_commands
|
||||||
|
from dify_agent.runtime_backend import (
|
||||||
|
BindingAcquireError,
|
||||||
|
BindingLostError,
|
||||||
|
ExecutionBindingBackend,
|
||||||
|
RuntimeLayout,
|
||||||
|
WorkspaceUnavailableError,
|
||||||
|
)
|
||||||
|
from dify_agent.runtime_backend.leases import open_runtime_lease
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_LIST_MAX_ENTRIES = 1000
|
||||||
|
_BROWSE_TIMEOUT_SECONDS = 60.0
|
||||||
|
_BROWSE_OUTPUT_MAX_BYTES = 1024 * 1024
|
||||||
|
_DOWNLOAD_TIMEOUT_SECONDS = 60.0
|
||||||
|
_DOWNLOAD_OUTPUT_MAX_BYTES = 32 * 1024
|
||||||
|
_PAYLOAD_BEGIN = "<<<DIFY_BINDING_FILE_BEGIN>>>"
|
||||||
|
_PAYLOAD_END = "<<<DIFY_BINDING_FILE_END>>>"
|
||||||
|
|
||||||
|
_LIST_BINDING_FILES_SCRIPT = r"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path = sys.argv[1]
|
||||||
|
response_path = sys.argv[2]
|
||||||
|
limit = int(sys.argv[3])
|
||||||
|
directory_fd = None
|
||||||
|
try:
|
||||||
|
directory_fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
||||||
|
names = sorted(os.listdir(directory_fd))
|
||||||
|
entries = []
|
||||||
|
for name in names[:limit]:
|
||||||
|
child_stat = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
|
||||||
|
mode = child_stat.st_mode
|
||||||
|
entry_type = (
|
||||||
|
"symlink" if stat.S_ISLNK(mode) else
|
||||||
|
"dir" if stat.S_ISDIR(mode) else
|
||||||
|
"file" if stat.S_ISREG(mode) else
|
||||||
|
"other"
|
||||||
|
)
|
||||||
|
entries.append({
|
||||||
|
"name": name,
|
||||||
|
"type": entry_type,
|
||||||
|
"size": int(child_stat.st_size),
|
||||||
|
"mtime": int(child_stat.st_mtime),
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
if directory_fd is not None:
|
||||||
|
os.close(directory_fd)
|
||||||
|
|
||||||
|
payload = {"path": response_path, "entries": entries, "truncated": len(names) > limit}
|
||||||
|
blob = base64.b64encode(json.dumps(payload, separators=(",", ":")).encode()).decode()
|
||||||
|
print("<<<DIFY_BINDING_FILE_BEGIN>>>" + blob + "<<<DIFY_BINDING_FILE_END>>>")
|
||||||
|
"""
|
||||||
|
|
||||||
|
_READ_BINDING_FILE_SCRIPT = r"""
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import stat
|
||||||
|
import sys
|
||||||
|
|
||||||
|
path = sys.argv[1]
|
||||||
|
response_path = sys.argv[2]
|
||||||
|
max_bytes = int(sys.argv[3])
|
||||||
|
file_fd = None
|
||||||
|
try:
|
||||||
|
file_fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
||||||
|
file_stat = os.fstat(file_fd)
|
||||||
|
if not stat.S_ISREG(file_stat.st_mode):
|
||||||
|
raise FileNotFoundError(path)
|
||||||
|
size = int(file_stat.st_size)
|
||||||
|
data = os.read(file_fd, max_bytes + 1)
|
||||||
|
finally:
|
||||||
|
if file_fd is not None:
|
||||||
|
os.close(file_fd)
|
||||||
|
|
||||||
|
truncated = len(data) > max_bytes
|
||||||
|
data = data[:max_bytes]
|
||||||
|
try:
|
||||||
|
text = data.decode("utf-8")
|
||||||
|
binary = False
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
text = None
|
||||||
|
binary = True
|
||||||
|
payload = {
|
||||||
|
"path": response_path,
|
||||||
|
"size": size,
|
||||||
|
"truncated": truncated,
|
||||||
|
"binary": binary,
|
||||||
|
"text": text,
|
||||||
|
}
|
||||||
|
blob = base64.b64encode(json.dumps(payload, separators=(",", ":")).encode()).decode()
|
||||||
|
print("<<<DIFY_BINDING_FILE_BEGIN>>>" + blob + "<<<DIFY_BINDING_FILE_END>>>")
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class BindingFileError(Exception):
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
status_code: int
|
||||||
|
|
||||||
|
def __init__(self, code: str, message: str, *, status_code: int = 400) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
|
class _CliUploadResult(BaseModel):
|
||||||
|
transfer_method: Literal["tool_file"]
|
||||||
|
reference: str
|
||||||
|
|
||||||
|
model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class BindingFileService:
|
||||||
|
execution_bindings: ExecutionBindingBackend
|
||||||
|
agent_stub_api_base_url: str | None
|
||||||
|
agent_stub_token_factory: ShellAgentStubTokenFactory | None
|
||||||
|
|
||||||
|
async def list_files(self, request: BindingFileListRequest) -> BindingFileListResponse:
|
||||||
|
try:
|
||||||
|
async with open_runtime_lease(self.execution_bindings, request.backend_binding_ref) as lease:
|
||||||
|
resolved_path = resolve_binding_path(request.path, lease.layout)
|
||||||
|
result = await execute_complete_with_commands(
|
||||||
|
lease.commands,
|
||||||
|
_python_command(
|
||||||
|
_LIST_BINDING_FILES_SCRIPT,
|
||||||
|
resolved_path,
|
||||||
|
request.path,
|
||||||
|
str(_LIST_MAX_ENTRIES),
|
||||||
|
),
|
||||||
|
cwd=lease.layout.workspace_dir,
|
||||||
|
env={"HOME": lease.layout.home_dir},
|
||||||
|
timeout=_BROWSE_TIMEOUT_SECONDS,
|
||||||
|
max_output_bytes=_BROWSE_OUTPUT_MAX_BYTES,
|
||||||
|
)
|
||||||
|
payload = _require_browse_payload(result, operation="list")
|
||||||
|
try:
|
||||||
|
return BindingFileListResponse.model_validate(payload)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise WorkspaceUnavailableError("Binding file list returned an invalid response") from exc
|
||||||
|
except BindingFileError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise _normalize_binding_file_error(exc) from exc
|
||||||
|
|
||||||
|
async def read_file(self, request: BindingFileReadRequest) -> BindingFileReadResponse:
|
||||||
|
try:
|
||||||
|
async with open_runtime_lease(self.execution_bindings, request.backend_binding_ref) as lease:
|
||||||
|
resolved_path = resolve_binding_path(request.path, lease.layout)
|
||||||
|
result = await execute_complete_with_commands(
|
||||||
|
lease.commands,
|
||||||
|
_python_command(
|
||||||
|
_READ_BINDING_FILE_SCRIPT,
|
||||||
|
resolved_path,
|
||||||
|
request.path,
|
||||||
|
str(request.max_bytes),
|
||||||
|
),
|
||||||
|
cwd=lease.layout.workspace_dir,
|
||||||
|
env={"HOME": lease.layout.home_dir},
|
||||||
|
timeout=_BROWSE_TIMEOUT_SECONDS,
|
||||||
|
max_output_bytes=_BROWSE_OUTPUT_MAX_BYTES,
|
||||||
|
)
|
||||||
|
payload = _require_browse_payload(result, operation="read")
|
||||||
|
try:
|
||||||
|
return BindingFileReadResponse.model_validate(payload)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise WorkspaceUnavailableError("Binding file read returned an invalid response") from exc
|
||||||
|
except BindingFileError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
raise _normalize_binding_file_error(exc) from exc
|
||||||
|
|
||||||
|
async def download_file(self, request: BindingFileDownloadRequest) -> BindingFileDownloadResponse:
|
||||||
|
context = request.execution_context
|
||||||
|
if not context.user_id or not context.user_from:
|
||||||
|
raise BindingFileError(
|
||||||
|
"invalid_execution_context",
|
||||||
|
"Binding file download requires user_id and user_from",
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
if self.agent_stub_api_base_url is None or self.agent_stub_token_factory is None:
|
||||||
|
raise BindingFileError(
|
||||||
|
"agent_stub_upload_unavailable",
|
||||||
|
"Agent Stub file upload is not configured",
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
agent_stub_env = build_shell_agent_stub_env(
|
||||||
|
agent_stub_api_base_url=self.agent_stub_api_base_url,
|
||||||
|
execution_context=context,
|
||||||
|
token_factory=self.agent_stub_token_factory,
|
||||||
|
session_id=None,
|
||||||
|
)
|
||||||
|
if agent_stub_env is None:
|
||||||
|
raise BindingFileError(
|
||||||
|
"agent_stub_upload_unavailable",
|
||||||
|
"Agent Stub file upload is not configured",
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
|
async with open_runtime_lease(self.execution_bindings, request.backend_binding_ref) as lease:
|
||||||
|
resolved_path = resolve_binding_path(request.path, lease.layout)
|
||||||
|
env = {"HOME": lease.layout.home_dir, **agent_stub_env}
|
||||||
|
try:
|
||||||
|
result = await execute_complete_with_commands(
|
||||||
|
lease.commands,
|
||||||
|
f"dify-agent file upload --no-download-link {shlex.quote(resolved_path)}",
|
||||||
|
cwd=lease.layout.workspace_dir,
|
||||||
|
env=env,
|
||||||
|
timeout=_DOWNLOAD_TIMEOUT_SECONDS,
|
||||||
|
max_output_bytes=_DOWNLOAD_OUTPUT_MAX_BYTES,
|
||||||
|
)
|
||||||
|
except ShellProviderError as exc:
|
||||||
|
if exc.code == "timeout":
|
||||||
|
raise _download_failed() from exc
|
||||||
|
raise
|
||||||
|
if result.exit_code != 0 or not result.output_complete:
|
||||||
|
_log_download_failure(result.output, agent_stub_env)
|
||||||
|
raise _download_failed()
|
||||||
|
try:
|
||||||
|
payload = json.loads(result.output)
|
||||||
|
uploaded = _CliUploadResult.model_validate(payload)
|
||||||
|
except (json.JSONDecodeError, ValidationError, TypeError) as exc:
|
||||||
|
raise _download_failed() from exc
|
||||||
|
if not is_canonical_dify_file_reference(uploaded.reference):
|
||||||
|
raise _download_failed()
|
||||||
|
return BindingFileDownloadResponse(reference=uploaded.reference)
|
||||||
|
except BindingFileError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
normalized = _normalize_binding_file_error(exc)
|
||||||
|
if normalized.code in {"binding_not_found", "binding_unavailable"}:
|
||||||
|
raise normalized from exc
|
||||||
|
raise _download_failed() from exc
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_binding_path(path: str, layout: RuntimeLayout) -> str:
|
||||||
|
"""Resolve convenient Binding paths without adding a containment policy."""
|
||||||
|
|
||||||
|
if path == "~":
|
||||||
|
candidate = layout.home_dir
|
||||||
|
elif path.startswith("~/"):
|
||||||
|
candidate = posixpath.join(layout.home_dir, path[2:])
|
||||||
|
elif posixpath.isabs(path):
|
||||||
|
candidate = path
|
||||||
|
else:
|
||||||
|
candidate = posixpath.join(layout.workspace_dir, path or ".")
|
||||||
|
return posixpath.normpath(candidate)
|
||||||
|
|
||||||
|
|
||||||
|
def _python_command(source: str, *args: str) -> str:
|
||||||
|
return " ".join(["python3", "-c", shlex.quote(source), *(shlex.quote(arg) for arg in args)])
|
||||||
|
|
||||||
|
|
||||||
|
def _require_browse_payload(result: CompleteShellCommandResult, *, operation: str) -> dict[str, object]:
|
||||||
|
exit_code = result.exit_code
|
||||||
|
output_complete = result.output_complete
|
||||||
|
output = result.output
|
||||||
|
if exit_code != 0:
|
||||||
|
if any(
|
||||||
|
name in output
|
||||||
|
for name in ("FileNotFoundError", "NotADirectoryError", "IsADirectoryError", "PermissionError")
|
||||||
|
):
|
||||||
|
raise BindingFileError(
|
||||||
|
"invalid_binding_path",
|
||||||
|
f"Binding file {operation} path is unavailable",
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
raise WorkspaceUnavailableError(f"Binding file {operation} command failed")
|
||||||
|
if not output_complete:
|
||||||
|
raise WorkspaceUnavailableError(f"Binding file {operation} output was incomplete")
|
||||||
|
match = re.search(re.escape(_PAYLOAD_BEGIN) + r"(.*?)" + re.escape(_PAYLOAD_END), output, flags=re.DOTALL)
|
||||||
|
if match is None:
|
||||||
|
raise WorkspaceUnavailableError(f"Binding file {operation} returned no framed payload")
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode("".join(match.group(1).split()), validate=True)
|
||||||
|
payload = json.loads(decoded)
|
||||||
|
except (ValueError, json.JSONDecodeError) as exc:
|
||||||
|
raise WorkspaceUnavailableError(f"Binding file {operation} returned an invalid payload") from exc
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise WorkspaceUnavailableError(f"Binding file {operation} returned a non-object payload")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_binding_file_error(exc: Exception) -> BindingFileError:
|
||||||
|
if isinstance(exc, BindingFileError):
|
||||||
|
return exc
|
||||||
|
if isinstance(exc, ValueError):
|
||||||
|
return BindingFileError("invalid_binding_path", "Binding file path or payload is invalid", status_code=400)
|
||||||
|
if isinstance(exc, BindingLostError):
|
||||||
|
return BindingFileError("binding_not_found", "Execution Binding was not found", status_code=404)
|
||||||
|
if isinstance(exc, BindingAcquireError | WorkspaceUnavailableError):
|
||||||
|
return BindingFileError("binding_unavailable", "Execution Binding is unavailable", status_code=502)
|
||||||
|
return BindingFileError("binding_unavailable", "Execution Binding file operation failed", status_code=502)
|
||||||
|
|
||||||
|
|
||||||
|
def _download_failed() -> BindingFileError:
|
||||||
|
return BindingFileError(
|
||||||
|
"binding_file_download_failed",
|
||||||
|
"Binding file could not be converted to a ToolFile",
|
||||||
|
status_code=502,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _log_download_failure(output: str, env: dict[str, str]) -> None:
|
||||||
|
redacted = output
|
||||||
|
for secret in env.values():
|
||||||
|
if len(secret) > 8:
|
||||||
|
redacted = redacted.replace(secret, "***")
|
||||||
|
logger.warning("Binding file upload command failed: %s", redacted[-1024:])
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["BindingFileError", "BindingFileService", "resolve_binding_path"]
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
"""Private Binding file routes used by Dify API."""
|
||||||
|
|
||||||
|
from collections.abc import Callable, Coroutine
|
||||||
|
from typing import Annotated, Any, override
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.routing import APIRoute
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from dify_agent.protocol import (
|
||||||
|
BindingFileDownloadRequest,
|
||||||
|
BindingFileDownloadResponse,
|
||||||
|
BindingFileListRequest,
|
||||||
|
BindingFileListResponse,
|
||||||
|
BindingFileReadRequest,
|
||||||
|
BindingFileReadResponse,
|
||||||
|
)
|
||||||
|
from dify_agent.server.binding_files import BindingFileError, BindingFileService
|
||||||
|
|
||||||
|
_INVALID_BINDING_PATH_MESSAGE = "Binding file path or payload is invalid"
|
||||||
|
_BROWSE_PATHS = frozenset(
|
||||||
|
{
|
||||||
|
"/execution-bindings/files/list",
|
||||||
|
"/execution-bindings/files/read",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _BindingFileValidationRoute(APIRoute):
|
||||||
|
@override
|
||||||
|
def get_route_handler(self) -> Callable[[Request], Coroutine[Any, Any, Response]]:
|
||||||
|
route_handler = super().get_route_handler()
|
||||||
|
|
||||||
|
async def handle(request: Request) -> Response:
|
||||||
|
try:
|
||||||
|
return await route_handler(request)
|
||||||
|
except RequestValidationError:
|
||||||
|
if self.path not in _BROWSE_PATHS:
|
||||||
|
raise
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={
|
||||||
|
"detail": {
|
||||||
|
"code": "invalid_binding_path",
|
||||||
|
"message": _INVALID_BINDING_PATH_MESSAGE,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return handle
|
||||||
|
|
||||||
|
|
||||||
|
def create_binding_files_router(get_service: Callable[[], BindingFileService | None]) -> APIRouter:
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/execution-bindings/files",
|
||||||
|
tags=["execution-bindings"],
|
||||||
|
route_class=_BindingFileValidationRoute,
|
||||||
|
)
|
||||||
|
|
||||||
|
def service_dep() -> BindingFileService:
|
||||||
|
service = get_service()
|
||||||
|
if service is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail={"code": "runtime_backend_unavailable", "message": "Binding file service is not configured"},
|
||||||
|
)
|
||||||
|
return service
|
||||||
|
|
||||||
|
def raise_http(exc: BindingFileError) -> HTTPException:
|
||||||
|
return HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message})
|
||||||
|
|
||||||
|
@router.post("/list", response_model=BindingFileListResponse)
|
||||||
|
async def list_files(
|
||||||
|
request: BindingFileListRequest,
|
||||||
|
service: Annotated[BindingFileService, Depends(service_dep)],
|
||||||
|
) -> BindingFileListResponse:
|
||||||
|
try:
|
||||||
|
return await service.list_files(request)
|
||||||
|
except BindingFileError as exc:
|
||||||
|
raise raise_http(exc) from exc
|
||||||
|
|
||||||
|
@router.post("/read", response_model=BindingFileReadResponse)
|
||||||
|
async def read_file(
|
||||||
|
request: BindingFileReadRequest,
|
||||||
|
service: Annotated[BindingFileService, Depends(service_dep)],
|
||||||
|
) -> BindingFileReadResponse:
|
||||||
|
try:
|
||||||
|
return await service.read_file(request)
|
||||||
|
except BindingFileError as exc:
|
||||||
|
raise raise_http(exc) from exc
|
||||||
|
|
||||||
|
@router.post("/download", response_model=BindingFileDownloadResponse)
|
||||||
|
async def download_file(
|
||||||
|
request: BindingFileDownloadRequest,
|
||||||
|
service: Annotated[BindingFileService, Depends(service_dep)],
|
||||||
|
) -> BindingFileDownloadResponse:
|
||||||
|
try:
|
||||||
|
return await service.download_file(request)
|
||||||
|
except BindingFileError as exc:
|
||||||
|
raise raise_http(exc) from exc
|
||||||
|
|
||||||
|
return router
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["create_binding_files_router"]
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"""Private Workspace file routes used by Dify API."""
|
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from typing import Annotated
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
|
||||||
|
|
||||||
from dify_agent.protocol import (
|
|
||||||
WorkspaceListRequest,
|
|
||||||
WorkspaceListResponse,
|
|
||||||
WorkspaceReadRequest,
|
|
||||||
WorkspaceReadResponse,
|
|
||||||
WorkspaceUploadRequest,
|
|
||||||
WorkspaceUploadResponse,
|
|
||||||
)
|
|
||||||
from dify_agent.server.workspace_files import WorkspaceFileError, WorkspaceFileService
|
|
||||||
|
|
||||||
|
|
||||||
def create_workspace_files_router(get_service: Callable[[], WorkspaceFileService | None]) -> APIRouter:
|
|
||||||
router = APIRouter(prefix="/workspace", tags=["workspace"])
|
|
||||||
|
|
||||||
def service_dep() -> WorkspaceFileService:
|
|
||||||
service = get_service()
|
|
||||||
if service is None:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=503,
|
|
||||||
detail={"code": "runtime_backend_unavailable", "message": "Workspace service is not configured"},
|
|
||||||
)
|
|
||||||
return service
|
|
||||||
|
|
||||||
def raise_http(exc: WorkspaceFileError) -> HTTPException:
|
|
||||||
return HTTPException(status_code=exc.status_code, detail={"code": exc.code, "message": exc.message})
|
|
||||||
|
|
||||||
@router.post("/files/list", response_model=WorkspaceListResponse)
|
|
||||||
async def list_files(
|
|
||||||
request: WorkspaceListRequest,
|
|
||||||
service: Annotated[WorkspaceFileService, Depends(service_dep)],
|
|
||||||
) -> WorkspaceListResponse:
|
|
||||||
try:
|
|
||||||
return await service.list_files(request)
|
|
||||||
except WorkspaceFileError as exc:
|
|
||||||
raise raise_http(exc) from exc
|
|
||||||
|
|
||||||
@router.post("/files/read", response_model=WorkspaceReadResponse)
|
|
||||||
async def read_file(
|
|
||||||
request: WorkspaceReadRequest,
|
|
||||||
service: Annotated[WorkspaceFileService, Depends(service_dep)],
|
|
||||||
) -> WorkspaceReadResponse:
|
|
||||||
try:
|
|
||||||
return await service.read_file(request)
|
|
||||||
except WorkspaceFileError as exc:
|
|
||||||
raise raise_http(exc) from exc
|
|
||||||
|
|
||||||
@router.post("/files/upload", response_model=WorkspaceUploadResponse)
|
|
||||||
async def upload_file(
|
|
||||||
request: WorkspaceUploadRequest,
|
|
||||||
service: Annotated[WorkspaceFileService, Depends(service_dep)],
|
|
||||||
) -> WorkspaceUploadResponse:
|
|
||||||
try:
|
|
||||||
return await service.upload_file(request)
|
|
||||||
except WorkspaceFileError as exc:
|
|
||||||
raise raise_http(exc) from exc
|
|
||||||
|
|
||||||
return router
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["create_workspace_files_router"]
|
|
||||||
@@ -71,7 +71,6 @@ class ServerSettings(BaseSettings):
|
|||||||
)
|
)
|
||||||
e2b_shellctl_auth_token: str = ""
|
e2b_shellctl_auth_token: str = ""
|
||||||
e2b_shellctl_port: int = Field(default=5004, ge=1, le=65535)
|
e2b_shellctl_port: int = Field(default=5004, ge=1, le=65535)
|
||||||
sandbox_file_upload_max_bytes: int = Field(default=50 * 1024 * 1024, ge=1)
|
|
||||||
agent_stub_api_base_url: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_API_BASE_URL")
|
agent_stub_api_base_url: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_API_BASE_URL")
|
||||||
sandbox_files_base_url: str | None = Field(
|
sandbox_files_base_url: str | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
|
|||||||
@@ -1,222 +0,0 @@
|
|||||||
"""Workspace file access through an operation-scoped RuntimeLease.
|
|
||||||
|
|
||||||
Paths are passed directly to the backend FileSystem. Dify Agent does not rebase
|
|
||||||
them to ``layout.workspace_dir`` or impose another containment boundary.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import asdict, dataclass
|
|
||||||
import mimetypes
|
|
||||||
from pathlib import PurePosixPath
|
|
||||||
from typing import ClassVar, Protocol
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
||||||
|
|
||||||
from dify_agent.agent_stub.protocol import (
|
|
||||||
AgentStubFileDownloadRequest,
|
|
||||||
AgentStubFileMapping,
|
|
||||||
AgentStubFileUploadRequest,
|
|
||||||
)
|
|
||||||
from dify_agent.agent_stub.server.agent_stub_files import AgentStubFileRequestError, AgentStubFileRequestHandler
|
|
||||||
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubPrincipal
|
|
||||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
|
||||||
from dify_agent.protocol import (
|
|
||||||
WorkspaceListRequest,
|
|
||||||
WorkspaceListResponse,
|
|
||||||
WorkspaceReadRequest,
|
|
||||||
WorkspaceReadResponse,
|
|
||||||
WorkspaceUploadRequest,
|
|
||||||
WorkspaceUploadResponse,
|
|
||||||
WorkspaceUploadedFile,
|
|
||||||
)
|
|
||||||
from dify_agent.runtime_backend import (
|
|
||||||
BindingAcquireError,
|
|
||||||
BindingLostError,
|
|
||||||
ExecutionBindingBackend,
|
|
||||||
WorkspaceFileTooLargeError,
|
|
||||||
WorkspacePathError,
|
|
||||||
WorkspaceUnavailableError,
|
|
||||||
)
|
|
||||||
from dify_agent.runtime_backend.leases import open_runtime_lease
|
|
||||||
|
|
||||||
_LIST_MAX_ENTRIES = 1000
|
|
||||||
_UPLOAD_TIMEOUT_SECONDS = 30.0
|
|
||||||
|
|
||||||
|
|
||||||
class _SignedUploadResponse(BaseModel):
|
|
||||||
reference: str
|
|
||||||
|
|
||||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceFileError(Exception):
|
|
||||||
code: str
|
|
||||||
message: str
|
|
||||||
status_code: int
|
|
||||||
|
|
||||||
def __init__(self, code: str, message: str, *, status_code: int = 400) -> None:
|
|
||||||
super().__init__(message)
|
|
||||||
self.code = code
|
|
||||||
self.message = message
|
|
||||||
self.status_code = status_code
|
|
||||||
|
|
||||||
|
|
||||||
class WorkspaceFileUploader(Protocol):
|
|
||||||
async def upload(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
execution_context: DifyExecutionContextLayerConfig,
|
|
||||||
filename: str,
|
|
||||||
mimetype: str,
|
|
||||||
content: bytes,
|
|
||||||
) -> WorkspaceUploadedFile: ...
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class AgentStubWorkspaceFileUploader:
|
|
||||||
file_request_handler: AgentStubFileRequestHandler
|
|
||||||
timeout: httpx.Timeout | float = _UPLOAD_TIMEOUT_SECONDS
|
|
||||||
transport: httpx.AsyncBaseTransport | None = None
|
|
||||||
|
|
||||||
async def upload(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
execution_context: DifyExecutionContextLayerConfig,
|
|
||||||
filename: str,
|
|
||||||
mimetype: str,
|
|
||||||
content: bytes,
|
|
||||||
) -> WorkspaceUploadedFile:
|
|
||||||
principal = AgentStubPrincipal(
|
|
||||||
execution_context=execution_context,
|
|
||||||
session_id=None,
|
|
||||||
scope=[],
|
|
||||||
token_id="workspace-file-upload",
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
upload_request = await self.file_request_handler.create_upload_request(
|
|
||||||
principal=principal,
|
|
||||||
request=AgentStubFileUploadRequest(filename=filename, mimetype=mimetype),
|
|
||||||
)
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
timeout=self.timeout,
|
|
||||||
follow_redirects=True,
|
|
||||||
trust_env=False,
|
|
||||||
transport=self.transport,
|
|
||||||
) as client:
|
|
||||||
response = await client.post(
|
|
||||||
upload_request.upload_url,
|
|
||||||
files={"file": (filename, content, mimetype)},
|
|
||||||
)
|
|
||||||
_ = response.raise_for_status()
|
|
||||||
payload = _SignedUploadResponse.model_validate(response.json())
|
|
||||||
mapping = AgentStubFileMapping(transfer_method="tool_file", reference=payload.reference)
|
|
||||||
download_request = await self.file_request_handler.create_download_request(
|
|
||||||
principal=principal,
|
|
||||||
request=AgentStubFileDownloadRequest(file=mapping, for_frontend=False),
|
|
||||||
)
|
|
||||||
return WorkspaceUploadedFile(
|
|
||||||
reference=payload.reference,
|
|
||||||
download_url=download_request.download_url,
|
|
||||||
)
|
|
||||||
except AgentStubFileRequestError as exc:
|
|
||||||
raise WorkspaceFileError("agent_stub_upload_failed", str(exc.detail), status_code=exc.status_code) from exc
|
|
||||||
except httpx.TimeoutException as exc:
|
|
||||||
raise WorkspaceFileError(
|
|
||||||
"agent_stub_upload_failed", "signed file upload timed out", status_code=504
|
|
||||||
) from exc
|
|
||||||
except httpx.HTTPStatusError as exc:
|
|
||||||
raise WorkspaceFileError(
|
|
||||||
"agent_stub_upload_failed",
|
|
||||||
f"signed file upload failed with status {exc.response.status_code}",
|
|
||||||
status_code=exc.response.status_code,
|
|
||||||
) from exc
|
|
||||||
except httpx.RequestError as exc:
|
|
||||||
raise WorkspaceFileError(
|
|
||||||
"agent_stub_upload_failed", f"signed file upload failed: {exc}", status_code=502
|
|
||||||
) from exc
|
|
||||||
except (ValidationError, ValueError) as exc:
|
|
||||||
raise WorkspaceFileError(
|
|
||||||
"agent_stub_upload_failed", "signed file upload returned invalid data", status_code=502
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class WorkspaceFileService:
|
|
||||||
execution_bindings: ExecutionBindingBackend
|
|
||||||
upload_max_bytes: int
|
|
||||||
file_uploader: WorkspaceFileUploader | None = None
|
|
||||||
|
|
||||||
async def list_files(self, request: WorkspaceListRequest) -> WorkspaceListResponse:
|
|
||||||
try:
|
|
||||||
async with open_runtime_lease(self.execution_bindings, request.backend_binding_ref) as lease:
|
|
||||||
result = await lease.files.list_directory(path=request.path, limit=_LIST_MAX_ENTRIES)
|
|
||||||
return WorkspaceListResponse.model_validate(
|
|
||||||
{
|
|
||||||
"path": result.path,
|
|
||||||
"entries": [asdict(entry) for entry in result.entries],
|
|
||||||
"truncated": result.truncated,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
raise _normalize_file_error(exc) from exc
|
|
||||||
|
|
||||||
async def read_file(self, request: WorkspaceReadRequest) -> WorkspaceReadResponse:
|
|
||||||
try:
|
|
||||||
async with open_runtime_lease(self.execution_bindings, request.backend_binding_ref) as lease:
|
|
||||||
result = await lease.files.read_file(path=request.path, max_bytes=request.max_bytes)
|
|
||||||
return WorkspaceReadResponse(
|
|
||||||
path=result.path,
|
|
||||||
size=result.size,
|
|
||||||
truncated=result.truncated,
|
|
||||||
binary=result.binary,
|
|
||||||
text=result.text,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
raise _normalize_file_error(exc) from exc
|
|
||||||
|
|
||||||
async def upload_file(self, request: WorkspaceUploadRequest) -> WorkspaceUploadResponse:
|
|
||||||
uploader = self.file_uploader
|
|
||||||
if uploader is None:
|
|
||||||
raise WorkspaceFileError(
|
|
||||||
"agent_stub_upload_unavailable", "Agent Stub file upload is not configured", status_code=503
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
async with open_runtime_lease(self.execution_bindings, request.backend_binding_ref) as lease:
|
|
||||||
result = await lease.files.read_bytes(path=request.path, max_bytes=self.upload_max_bytes)
|
|
||||||
filename = PurePosixPath(result.path).name or "file"
|
|
||||||
mimetype = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
||||||
uploaded_file = await uploader.upload(
|
|
||||||
execution_context=request.execution_context,
|
|
||||||
filename=filename,
|
|
||||||
mimetype=mimetype,
|
|
||||||
content=result.content,
|
|
||||||
)
|
|
||||||
return WorkspaceUploadResponse(path=result.path, file=uploaded_file)
|
|
||||||
except WorkspaceFileError:
|
|
||||||
raise
|
|
||||||
except Exception as exc:
|
|
||||||
raise _normalize_file_error(exc) from exc
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_file_error(exc: Exception) -> WorkspaceFileError:
|
|
||||||
if isinstance(exc, WorkspaceFileError):
|
|
||||||
return exc
|
|
||||||
if isinstance(exc, WorkspacePathError):
|
|
||||||
return WorkspaceFileError("invalid_workspace_path", str(exc), status_code=400)
|
|
||||||
if isinstance(exc, WorkspaceFileTooLargeError):
|
|
||||||
return WorkspaceFileError("file_too_large", str(exc), status_code=413)
|
|
||||||
if isinstance(exc, BindingLostError):
|
|
||||||
return WorkspaceFileError("binding_not_found", str(exc), status_code=404)
|
|
||||||
if isinstance(exc, (WorkspaceUnavailableError, BindingAcquireError)):
|
|
||||||
return WorkspaceFileError("workspace_unavailable", str(exc), status_code=502)
|
|
||||||
return WorkspaceFileError("workspace_file_failed", str(exc), status_code=502)
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
"AgentStubWorkspaceFileUploader",
|
|
||||||
"WorkspaceFileError",
|
|
||||||
"WorkspaceFileService",
|
|
||||||
"WorkspaceFileUploader",
|
|
||||||
]
|
|
||||||
+25
-10
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import shlex
|
||||||
import sys
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
@@ -20,10 +21,25 @@ from dify_agent.runtime_backend.e2b import (
|
|||||||
E2BSDKControlPlane,
|
E2BSDKControlPlane,
|
||||||
)
|
)
|
||||||
from dify_agent.runtime_backend.local import LocalExecutionBindingBackend
|
from dify_agent.runtime_backend.local import LocalExecutionBindingBackend
|
||||||
|
from dify_agent.runtime.command_runner import execute_complete_with_commands
|
||||||
|
|
||||||
pytestmark = pytest.mark.integration
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(lease, script: str, *, cwd: str) -> str:
|
||||||
|
result = await execute_complete_with_commands(
|
||||||
|
lease.commands,
|
||||||
|
script,
|
||||||
|
cwd=cwd,
|
||||||
|
env={"HOME": lease.layout.home_dir},
|
||||||
|
timeout=30.0,
|
||||||
|
max_output_bytes=4096,
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert result.output_complete
|
||||||
|
return result.output
|
||||||
|
|
||||||
|
|
||||||
def _required_env(name: str, purpose: str) -> str:
|
def _required_env(name: str, purpose: str) -> str:
|
||||||
value = os.environ.get(name, "").strip()
|
value = os.environ.get(name, "").strip()
|
||||||
if not value:
|
if not value:
|
||||||
@@ -53,9 +69,7 @@ async def test_local_two_agents_share_workspace_but_not_home() -> None:
|
|||||||
allocations.append(first)
|
allocations.append(first)
|
||||||
first_lease = await bindings.acquire(first.binding_ref)
|
first_lease = await bindings.acquire(first.binding_ref)
|
||||||
active_leases.append(first_lease)
|
active_leases.append(first_lease)
|
||||||
await first_lease.files.upload(
|
await _run(first_lease, "printf shared > shared.txt", cwd=first_lease.layout.workspace_dir)
|
||||||
content=b"shared", remote_path="shared.txt", cwd=first_lease.layout.workspace_dir
|
|
||||||
)
|
|
||||||
await bindings.release(first_lease)
|
await bindings.release(first_lease)
|
||||||
active_leases.remove(first_lease)
|
active_leases.remove(first_lease)
|
||||||
|
|
||||||
@@ -72,8 +86,8 @@ async def test_local_two_agents_share_workspace_but_not_home() -> None:
|
|||||||
allocations.append(second)
|
allocations.append(second)
|
||||||
second_lease = await bindings.acquire(second.binding_ref)
|
second_lease = await bindings.acquire(second.binding_ref)
|
||||||
active_leases.append(second_lease)
|
active_leases.append(second_lease)
|
||||||
shared = await second_lease.files.read_bytes(path="shared.txt", max_bytes=1024)
|
shared = await _run(second_lease, "cat shared.txt", cwd=second_lease.layout.workspace_dir)
|
||||||
assert shared.content == b"shared"
|
assert shared == "shared"
|
||||||
assert second_lease.layout.home_dir != first_lease.layout.home_dir
|
assert second_lease.layout.home_dir != first_lease.layout.home_dir
|
||||||
assert second_lease.layout.workspace_dir == first_lease.layout.workspace_dir
|
assert second_lease.layout.workspace_dir == first_lease.layout.workspace_dir
|
||||||
await bindings.release(second_lease)
|
await bindings.release(second_lease)
|
||||||
@@ -133,9 +147,9 @@ async def test_e2b_binding_checkpoint_and_collection() -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
lease = await bindings.acquire(allocation.binding_ref)
|
lease = await bindings.acquire(allocation.binding_ref)
|
||||||
await lease.files.upload(content=b"e2b", remote_path="probe.txt", cwd=lease.layout.workspace_dir)
|
await _run(lease, "printf e2b > probe.txt", cwd=lease.layout.workspace_dir)
|
||||||
await lease.files.upload(content=b"checkpoint-home", remote_path=".checkpoint-probe", cwd=lease.layout.home_dir)
|
await _run(lease, "printf checkpoint-home > .checkpoint-probe", cwd=lease.layout.home_dir)
|
||||||
assert (await lease.files.read_bytes(path="probe.txt", max_bytes=1024)).content == b"e2b"
|
assert await _run(lease, "cat probe.txt", cwd=lease.layout.workspace_dir) == "e2b"
|
||||||
checkpoint_ref = await snapshots.create_from_runtime(
|
checkpoint_ref = await snapshots.create_from_runtime(
|
||||||
spec=HomeSnapshotCreateSpec(
|
spec=HomeSnapshotCreateSpec(
|
||||||
tenant_id="integration-tenant",
|
tenant_id="integration-tenant",
|
||||||
@@ -158,8 +172,9 @@ async def test_e2b_binding_checkpoint_and_collection() -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
checkpoint_lease = await bindings.acquire(checkpoint_allocation.binding_ref)
|
checkpoint_lease = await bindings.acquire(checkpoint_allocation.binding_ref)
|
||||||
restored = await checkpoint_lease.files.read_bytes(path="~/.checkpoint-probe", max_bytes=1024)
|
checkpoint_path = shlex.quote(f"{checkpoint_lease.layout.home_dir}/.checkpoint-probe")
|
||||||
assert restored.content == b"checkpoint-home"
|
restored = await _run(checkpoint_lease, f"cat {checkpoint_path}", cwd=checkpoint_lease.layout.workspace_dir)
|
||||||
|
assert restored == "checkpoint-home"
|
||||||
await bindings.release(checkpoint_lease)
|
await bindings.release(checkpoint_lease)
|
||||||
checkpoint_lease = None
|
checkpoint_lease = None
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
@@ -1,43 +1,26 @@
|
|||||||
"""Local tests for the shellctl shell adapter and env-driven provider factory."""
|
"""Local tests for the shellctl command adapter."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
import signal
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
import httpx2 as httpx
|
import httpx2 as httpx
|
||||||
import pytest
|
import pytest
|
||||||
from shellctl.client import ShellctlClientError
|
from shellctl.client import ShellctlClientError
|
||||||
|
|
||||||
from dify_agent.adapters.shell import shellctl
|
from dify_agent.adapters.shell.protocols import ShellProviderError
|
||||||
from dify_agent.adapters.shell.protocols import ShellCommandResult, ShellProviderError
|
from dify_agent.adapters.shell.shellctl import ShellctlClientProtocol, ShellctlCommands
|
||||||
from dify_agent.adapters.shell.shellctl import (
|
|
||||||
ShellctlClientProtocol,
|
|
||||||
ShellctlCommands,
|
|
||||||
ShellFileTransferError,
|
|
||||||
ShellctlFileTransfer,
|
|
||||||
)
|
|
||||||
from dify_agent.runtime_backend.errors import WorkspaceFileTooLargeError
|
|
||||||
|
|
||||||
_WORKSPACE_SCRIPT_TIMEOUT_SECONDS = 5.0
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class _Job:
|
class _Job:
|
||||||
job_id: str
|
job_id: str = "job-1"
|
||||||
status: str = "running"
|
status: str = "exited"
|
||||||
done: bool = True
|
done: bool = True
|
||||||
output: str = ""
|
output: str = "ok"
|
||||||
offset: int = 0
|
offset: int = 2
|
||||||
truncated: bool = False
|
truncated: bool = False
|
||||||
exit_code: int | None = 0
|
exit_code: int | None = 0
|
||||||
output_path: str | None = "/tmp/output.log"
|
output_path: str | None = "/tmp/output.log"
|
||||||
@@ -45,664 +28,115 @@ class _Job:
|
|||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class _Status:
|
class _Status:
|
||||||
job_id: str
|
job_id: str = "job-1"
|
||||||
status: str = "terminated"
|
status: str = "terminated"
|
||||||
done: bool = True
|
done: bool = True
|
||||||
offset: int = 0
|
offset: int = 2
|
||||||
exit_code: int | None = 130
|
exit_code: int | None = 130
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class _RunCall:
|
class _Client:
|
||||||
script: str
|
run_result: object = field(default_factory=_Job)
|
||||||
cwd: str | None
|
delete_error: Exception | None = None
|
||||||
env: dict[str, str] | None
|
run_calls: list[tuple[str, str | None, dict[str, str] | None, float]] = field(default_factory=list)
|
||||||
timeout: float
|
|
||||||
|
|
||||||
|
|
||||||
type _RunHandler = Callable[[str, str | None, dict[str, str] | None, float], _Job]
|
|
||||||
type _WaitHandler = Callable[[str, int, float], _Job]
|
|
||||||
type _InputHandler = Callable[[str, str, int, float], _Job]
|
|
||||||
type _TerminateHandler = Callable[[str, float], _Status]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class FakeShellctlClient:
|
|
||||||
run_handler: _RunHandler | None = None
|
|
||||||
wait_handler: _WaitHandler | None = None
|
|
||||||
input_handler: _InputHandler | None = None
|
|
||||||
tail_handler: Callable[[str], _Job] | None = None
|
|
||||||
terminate_handler: _TerminateHandler | None = None
|
|
||||||
run_calls: list[_RunCall] = field(default_factory=list)
|
|
||||||
wait_calls: list[tuple[str, int, float]] = field(default_factory=list)
|
wait_calls: list[tuple[str, int, float]] = field(default_factory=list)
|
||||||
input_calls: list[tuple[str, str, int, float]] = field(default_factory=list)
|
|
||||||
terminate_calls: list[tuple[str, float]] = field(default_factory=list)
|
|
||||||
delete_calls: list[tuple[str, bool, float | None]] = field(default_factory=list)
|
delete_calls: list[tuple[str, bool, float | None]] = field(default_factory=list)
|
||||||
closed: bool = False
|
|
||||||
|
|
||||||
async def run(
|
async def run(self, script: str, *, cwd=None, env=None, timeout=30.0):
|
||||||
self,
|
self.run_calls.append((script, cwd, env, timeout))
|
||||||
script: str,
|
if isinstance(self.run_result, Exception):
|
||||||
*,
|
raise self.run_result
|
||||||
cwd: str | None = None,
|
return self.run_result
|
||||||
env: dict[str, str] | None = None,
|
|
||||||
timeout: float = 30.0,
|
|
||||||
) -> _Job:
|
|
||||||
self.run_calls.append(_RunCall(script=script, cwd=cwd, env=env, timeout=timeout))
|
|
||||||
if self.run_handler is not None:
|
|
||||||
return self.run_handler(script, cwd, env, timeout)
|
|
||||||
return _Job(job_id="job", status="exited", done=True, exit_code=0)
|
|
||||||
|
|
||||||
async def wait(self, job_id: str, *, offset: int, timeout: float = 30.0) -> _Job:
|
async def wait(self, job_id: str, *, offset: int, timeout=30.0):
|
||||||
self.wait_calls.append((job_id, offset, timeout))
|
self.wait_calls.append((job_id, offset, timeout))
|
||||||
if self.wait_handler is not None:
|
return _Job(job_id=job_id)
|
||||||
return self.wait_handler(job_id, offset, timeout)
|
|
||||||
return _Job(job_id=job_id, status="exited", done=True, offset=offset, exit_code=0)
|
|
||||||
|
|
||||||
async def input(
|
async def input(self, job_id: str, text: str, *, offset: int, timeout=30.0):
|
||||||
self,
|
return _Job(job_id=job_id)
|
||||||
job_id: str,
|
|
||||||
text: str,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
timeout: float = 30.0,
|
|
||||||
) -> _Job:
|
|
||||||
self.input_calls.append((job_id, text, offset, timeout))
|
|
||||||
if self.input_handler is not None:
|
|
||||||
return self.input_handler(job_id, text, offset, timeout)
|
|
||||||
return _Job(job_id=job_id, status="exited", done=True, offset=offset, exit_code=0)
|
|
||||||
|
|
||||||
async def tail(self, job_id: str) -> _Job:
|
async def tail(self, job_id: str):
|
||||||
if self.tail_handler is not None:
|
return _Job(job_id=job_id)
|
||||||
return self.tail_handler(job_id)
|
|
||||||
return _Job(job_id=job_id, status="exited", done=True, output="", exit_code=0)
|
|
||||||
|
|
||||||
async def terminate(self, job_id: str, grace_seconds: float = 10.0) -> _Status:
|
async def terminate(self, job_id: str, grace_seconds=10.0):
|
||||||
self.terminate_calls.append((job_id, grace_seconds))
|
|
||||||
if self.terminate_handler is not None:
|
|
||||||
return self.terminate_handler(job_id, grace_seconds)
|
|
||||||
return _Status(job_id=job_id)
|
return _Status(job_id=job_id)
|
||||||
|
|
||||||
async def delete(
|
async def delete(self, job_id: str, *, force=False, grace_seconds=None):
|
||||||
self,
|
|
||||||
job_id: str,
|
|
||||||
*,
|
|
||||||
force: bool = False,
|
|
||||||
grace_seconds: float | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.delete_calls.append((job_id, force, grace_seconds))
|
self.delete_calls.append((job_id, force, grace_seconds))
|
||||||
return None
|
if self.delete_error is not None:
|
||||||
|
raise self.delete_error
|
||||||
|
return object()
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
self.closed = True
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _client_protocol(client: FakeShellctlClient) -> ShellctlClientProtocol:
|
def _client(client: _Client) -> ShellctlClientProtocol:
|
||||||
return cast(ShellctlClientProtocol, cast(object, client))
|
return cast(ShellctlClientProtocol, cast(object, client))
|
||||||
|
|
||||||
|
|
||||||
def _kill_process_group(process: subprocess.Popen[str]) -> None:
|
def test_commands_apply_runtime_layout_and_home_environment() -> None:
|
||||||
try:
|
client = _Client()
|
||||||
os.killpg(process.pid, signal.SIGKILL)
|
|
||||||
except ProcessLookupError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def _run_workspace_source(source: str, args: list[str]) -> dict[str, object]:
|
|
||||||
process = subprocess.Popen(
|
|
||||||
[sys.executable, "-c", source, *args],
|
|
||||||
stdout=subprocess.PIPE,
|
|
||||||
stderr=subprocess.PIPE,
|
|
||||||
text=True,
|
|
||||||
start_new_session=True,
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
stdout, stderr = process.communicate(timeout=_WORKSPACE_SCRIPT_TIMEOUT_SECONDS)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
_kill_process_group(process)
|
|
||||||
_ = process.communicate()
|
|
||||||
pytest.fail(f"workspace script exceeded {_WORKSPACE_SCRIPT_TIMEOUT_SECONDS:g}s test timeout")
|
|
||||||
except BaseException:
|
|
||||||
_kill_process_group(process)
|
|
||||||
_ = process.communicate()
|
|
||||||
raise
|
|
||||||
assert process.returncode == 0, stderr
|
|
||||||
begin = stdout.find(shellctl._WORKSPACE_PAYLOAD_BEGIN)
|
|
||||||
end = stdout.find(shellctl._WORKSPACE_PAYLOAD_END, begin + len(shellctl._WORKSPACE_PAYLOAD_BEGIN))
|
|
||||||
assert begin >= 0 and end >= 0
|
|
||||||
encoded = "".join(stdout[begin + len(shellctl._WORKSPACE_PAYLOAD_BEGIN) : end].split())
|
|
||||||
payload = json.loads(base64.b64decode(encoded, validate=True))
|
|
||||||
assert isinstance(payload, dict)
|
|
||||||
return cast(dict[str, object], payload)
|
|
||||||
|
|
||||||
|
|
||||||
def _inject_workspace_checkpoint(source: str, checkpoint: str, injected_source: str) -> str:
|
|
||||||
marker = f"# DIFY_WORKSPACE_CHECKPOINT: {checkpoint}"
|
|
||||||
assert source.count(marker) == 1
|
|
||||||
marker_index = source.index(marker)
|
|
||||||
line_start = source.rfind("\n", 0, marker_index) + 1
|
|
||||||
indentation = source[line_start:marker_index]
|
|
||||||
assert not indentation.strip()
|
|
||||||
target = f"{indentation}{marker}"
|
|
||||||
injected = "\n".join(f"{indentation}{line}" if line else "" for line in injected_source.splitlines())
|
|
||||||
instrumented = source.replace(target, f"{injected}\n{target}", 1)
|
|
||||||
assert instrumented != source
|
|
||||||
assert instrumented.count(marker) == 1
|
|
||||||
return instrumented
|
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_read_holds_open_fd_across_concurrent_symlink_swap(tmp_path: Path) -> None:
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
reports = workspace / "reports"
|
|
||||||
reports.mkdir(parents=True)
|
|
||||||
source = reports / "result.pdf"
|
|
||||||
_ = source.write_text("workspace-content")
|
|
||||||
outside = tmp_path / "outside.pdf"
|
|
||||||
_ = outside.write_text("outside-content")
|
|
||||||
instrumented = _inject_workspace_checkpoint(
|
|
||||||
shellctl._READ_WORKSPACE_SCRIPT,
|
|
||||||
"file_opened",
|
|
||||||
"os.unlink(sys.argv[3])\nos.symlink(sys.argv[4], sys.argv[3])",
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = _run_workspace_source(
|
|
||||||
instrumented,
|
|
||||||
[str(source), "1024", str(source), str(outside)],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert payload["text"] == "workspace-content"
|
|
||||||
assert source.is_symlink()
|
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_read_bytes_holds_open_fd_across_same_uid_symlink_swap(tmp_path: Path) -> None:
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
reports = workspace / "reports"
|
|
||||||
reports.mkdir(parents=True)
|
|
||||||
source = reports / "result.pdf"
|
|
||||||
source.write_bytes(b"workspace-content\x00")
|
|
||||||
outside = tmp_path / "outside.pdf"
|
|
||||||
outside.write_bytes(b"outside-content")
|
|
||||||
instrumented = _inject_workspace_checkpoint(
|
|
||||||
shellctl._READ_WORKSPACE_BYTES_SCRIPT,
|
|
||||||
"file_opened",
|
|
||||||
"os.unlink(sys.argv[3])\nos.symlink(sys.argv[4], sys.argv[3])",
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = _run_workspace_source(
|
|
||||||
instrumented,
|
|
||||||
[str(source), "1024", str(source), str(outside)],
|
|
||||||
)
|
|
||||||
|
|
||||||
encoded = payload["content_base64"]
|
|
||||||
assert isinstance(encoded, str)
|
|
||||||
assert base64.b64decode(encoded) == b"workspace-content\x00"
|
|
||||||
assert source.is_symlink()
|
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_read_bytes_accepts_file_at_size_limit(tmp_path: Path) -> None:
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
workspace.mkdir()
|
|
||||||
source = workspace / "result.bin"
|
|
||||||
source.write_bytes(b"12345")
|
|
||||||
|
|
||||||
payload = _run_workspace_source(
|
|
||||||
shellctl._READ_WORKSPACE_BYTES_SCRIPT,
|
|
||||||
[str(source), "5"],
|
|
||||||
)
|
|
||||||
|
|
||||||
encoded = payload["content_base64"]
|
|
||||||
assert isinstance(encoded, str)
|
|
||||||
assert base64.b64decode(encoded) == b"12345"
|
|
||||||
assert payload["size"] == 5
|
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_read_bytes_rejects_oversize_before_remote_read(tmp_path: Path) -> None:
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
workspace.mkdir()
|
|
||||||
source = workspace / "result.bin"
|
|
||||||
source.write_bytes(b"123456")
|
|
||||||
instrumented = _inject_workspace_checkpoint(
|
|
||||||
shellctl._READ_WORKSPACE_BYTES_SCRIPT,
|
|
||||||
"arguments_loaded",
|
|
||||||
"os.read = lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError('must not read'))\n\n",
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = _run_workspace_source(
|
|
||||||
instrumented,
|
|
||||||
[str(source), "5"],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert payload == {"path": str(source), "size": 6, "too_large": True}
|
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_read_bytes_caps_capture_when_file_grows_after_fstat(tmp_path: Path) -> None:
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
workspace.mkdir()
|
|
||||||
source = workspace / "result.bin"
|
|
||||||
source.write_bytes(b"12345")
|
|
||||||
instrumented = _inject_workspace_checkpoint(
|
|
||||||
shellctl._READ_WORKSPACE_BYTES_SCRIPT,
|
|
||||||
"arguments_loaded",
|
|
||||||
"real_read = os.read\n"
|
|
||||||
"captured_bytes = 0\n"
|
|
||||||
"def bounded_read(fd, count):\n"
|
|
||||||
" global captured_bytes\n"
|
|
||||||
" data = real_read(fd, count)\n"
|
|
||||||
" captured_bytes += len(data)\n"
|
|
||||||
" assert captured_bytes <= int(sys.argv[2]) + 1\n"
|
|
||||||
" return data\n"
|
|
||||||
"os.read = bounded_read",
|
|
||||||
)
|
|
||||||
instrumented = _inject_workspace_checkpoint(
|
|
||||||
instrumented,
|
|
||||||
"file_size_captured",
|
|
||||||
"with open(sys.argv[3], 'ab') as growing:\n growing.write(b'x' * 10000)",
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = _run_workspace_source(
|
|
||||||
instrumented,
|
|
||||||
[str(source), "5", str(source)],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert payload == {"path": str(source), "size": 6, "too_large": True}
|
|
||||||
|
|
||||||
|
|
||||||
def test_workspace_list_holds_open_directory_fd_across_concurrent_symlink_swap(tmp_path: Path) -> None:
|
|
||||||
workspace = tmp_path / "workspace"
|
|
||||||
reports = workspace / "reports"
|
|
||||||
reports.mkdir(parents=True)
|
|
||||||
_ = (reports / "safe.txt").write_text("safe")
|
|
||||||
outside = tmp_path / "outside"
|
|
||||||
outside.mkdir()
|
|
||||||
_ = (outside / "secret.txt").write_text("secret")
|
|
||||||
moved = workspace / "opened-reports"
|
|
||||||
instrumented = _inject_workspace_checkpoint(
|
|
||||||
shellctl._LIST_WORKSPACE_SCRIPT,
|
|
||||||
"directory_opened",
|
|
||||||
"os.rename(sys.argv[3], sys.argv[4])\nos.symlink(sys.argv[5], sys.argv[3])",
|
|
||||||
)
|
|
||||||
|
|
||||||
payload = _run_workspace_source(
|
|
||||||
instrumented,
|
|
||||||
[str(reports), "100", str(reports), str(moved), str(outside)],
|
|
||||||
)
|
|
||||||
|
|
||||||
entries = payload["entries"]
|
|
||||||
assert isinstance(entries, list)
|
|
||||||
assert [entry["name"] for entry in entries if isinstance(entry, dict)] == ["safe.txt"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_commands_forward_parameters_and_map_metadata() -> None:
|
|
||||||
client = FakeShellctlClient(
|
|
||||||
run_handler=lambda script, cwd, env, timeout: _Job(
|
|
||||||
job_id="run-job",
|
|
||||||
status="running",
|
|
||||||
done=False,
|
|
||||||
output="abc",
|
|
||||||
offset=3,
|
|
||||||
truncated=True,
|
|
||||||
exit_code=None,
|
|
||||||
output_path="/tmp/run.log",
|
|
||||||
),
|
|
||||||
wait_handler=lambda job_id, offset, timeout: _Job(
|
|
||||||
job_id=job_id,
|
|
||||||
status="running",
|
|
||||||
done=False,
|
|
||||||
output="def",
|
|
||||||
offset=6,
|
|
||||||
truncated=False,
|
|
||||||
exit_code=None,
|
|
||||||
output_path="/tmp/run.log",
|
|
||||||
),
|
|
||||||
input_handler=lambda job_id, text, offset, timeout: _Job(
|
|
||||||
job_id=job_id,
|
|
||||||
status="exited",
|
|
||||||
done=True,
|
|
||||||
output="ghi",
|
|
||||||
offset=9,
|
|
||||||
truncated=False,
|
|
||||||
exit_code=0,
|
|
||||||
output_path="/tmp/run.log",
|
|
||||||
),
|
|
||||||
tail_handler=lambda job_id: _Job(
|
|
||||||
job_id=job_id,
|
|
||||||
status="exited",
|
|
||||||
done=True,
|
|
||||||
output="tail",
|
|
||||||
offset=11,
|
|
||||||
truncated=False,
|
|
||||||
exit_code=0,
|
|
||||||
output_path="/tmp/tail.log",
|
|
||||||
),
|
|
||||||
terminate_handler=lambda job_id, grace_seconds: _Status(
|
|
||||||
job_id=job_id,
|
|
||||||
status="terminated",
|
|
||||||
done=True,
|
|
||||||
offset=12,
|
|
||||||
exit_code=130,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
async def scenario() -> None:
|
async def scenario() -> None:
|
||||||
commands = ShellctlCommands(_client_protocol(client))
|
commands = ShellctlCommands(_client(client), home_dir="/home/binding", workspace_dir="/workspace")
|
||||||
run_result = await commands.run("pwd", cwd="~/workspace/abc12ff", env={"FOO": "bar"}, timeout=2.5)
|
result = await commands.run("pwd", cwd="reports", env={"TOKEN": "value"}, timeout=2.5)
|
||||||
wait_result = await commands.wait("run-job", offset=3, timeout=4.0)
|
assert result.output == "ok"
|
||||||
read_result = await commands.read_output("run-job", offset=6)
|
|
||||||
input_result = await commands.input("run-job", "ls\n", offset=6, timeout=5.0)
|
|
||||||
interrupt_result = await commands.interrupt("run-job", grace_seconds=1.5)
|
|
||||||
tail_result = await commands.tail("run-job")
|
|
||||||
await commands.delete("run-job", force=True, grace_seconds=2.0)
|
|
||||||
|
|
||||||
assert run_result == ShellCommandResult(
|
|
||||||
job_id="run-job",
|
|
||||||
status="running",
|
|
||||||
done=False,
|
|
||||||
exit_code=None,
|
|
||||||
output="abc",
|
|
||||||
offset=3,
|
|
||||||
truncated=True,
|
|
||||||
output_path="/tmp/run.log",
|
|
||||||
)
|
|
||||||
assert wait_result.offset == 6
|
|
||||||
assert read_result.offset == 6
|
|
||||||
assert input_result.exit_code == 0
|
|
||||||
assert interrupt_result.status == "terminated"
|
|
||||||
assert tail_result.output_path == "/tmp/tail.log"
|
|
||||||
|
|
||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
assert client.run_calls == [("pwd", "/workspace/reports", {"TOKEN": "value", "HOME": "/home/binding"}, 2.5)]
|
||||||
assert client.run_calls == [_RunCall(script="pwd", cwd="~/workspace/abc12ff", env={"FOO": "bar"}, timeout=2.5)]
|
|
||||||
assert client.wait_calls == [
|
|
||||||
("run-job", 3, 4.0),
|
|
||||||
("run-job", 6, 0.0),
|
|
||||||
]
|
|
||||||
assert client.input_calls == [("run-job", "ls\n", 6, 5.0)]
|
|
||||||
assert client.terminate_calls == [("run-job", 1.5)]
|
|
||||||
assert client.delete_calls == [("run-job", True, 2.0)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_commands_enforce_runtime_lease_home_and_cwd_namespace() -> None:
|
def test_commands_reject_cwd_outside_runtime_layout() -> None:
|
||||||
client = FakeShellctlClient()
|
|
||||||
|
|
||||||
async def scenario() -> None:
|
async def scenario() -> None:
|
||||||
commands = ShellctlCommands(
|
commands = ShellctlCommands(_client(_Client()), home_dir="/home/binding", workspace_dir="/workspace")
|
||||||
_client_protocol(client),
|
|
||||||
home_dir="/homes/binding-b",
|
|
||||||
workspace_dir="/workspaces/shared",
|
|
||||||
)
|
|
||||||
await commands.run("pwd", env={"HOME": "/homes/binding-a", "FOO": "bar"}, timeout=2.5)
|
|
||||||
await commands.run("pwd", cwd="~/project", timeout=2.5)
|
|
||||||
with pytest.raises(ValueError, match="outside this RuntimeLease"):
|
with pytest.raises(ValueError, match="outside this RuntimeLease"):
|
||||||
await commands.run("cat secret", cwd="/homes/binding-a", timeout=2.5)
|
await commands.run("pwd", cwd="/var/private", timeout=2.5)
|
||||||
|
|
||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
||||||
assert client.run_calls == [
|
|
||||||
_RunCall(
|
def test_read_output_uses_nonblocking_wait() -> None:
|
||||||
script="pwd",
|
client = _Client()
|
||||||
cwd="/workspaces/shared",
|
|
||||||
env={"HOME": "/homes/binding-b", "FOO": "bar"},
|
async def scenario() -> None:
|
||||||
timeout=2.5,
|
commands = ShellctlCommands(_client(client))
|
||||||
),
|
result = await commands.read_output("job-1", offset=7)
|
||||||
_RunCall(
|
assert result.job_id == "job-1"
|
||||||
script="pwd",
|
|
||||||
cwd="/homes/binding-b/project",
|
asyncio.run(scenario())
|
||||||
env={"HOME": "/homes/binding-b"},
|
assert client.wait_calls == [("job-1", 7, 0.0)]
|
||||||
timeout=2.5,
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def test_commands_map_http_timeout_to_shell_provider_error() -> None:
|
def test_commands_map_http_and_structured_errors() -> None:
|
||||||
request = httpx.Request("POST", "http://shellctl.example/v1/jobs")
|
request = httpx.Request("POST", "http://shellctl.example/v1/jobs")
|
||||||
client = FakeShellctlClient(
|
|
||||||
run_handler=lambda script, cwd, env, timeout: (_ for _ in ()).throw(
|
async def scenario() -> None:
|
||||||
httpx.ReadTimeout("timed out", request=request)
|
timeout_commands = ShellctlCommands(
|
||||||
|
_client(_Client(run_result=httpx.ReadTimeout("timed out", request=request)))
|
||||||
)
|
)
|
||||||
)
|
with pytest.raises(ShellProviderError) as timeout_error:
|
||||||
|
await timeout_commands.run("pwd", timeout=2.5)
|
||||||
|
assert timeout_error.value.code == "timeout"
|
||||||
|
|
||||||
async def scenario() -> None:
|
missing_commands = ShellctlCommands(
|
||||||
commands = ShellctlCommands(_client_protocol(client))
|
_client(_Client(run_result=ShellctlClientError(404, "sandbox_not_found", "expired")))
|
||||||
with pytest.raises(ShellProviderError, match="timed out") as exc_info:
|
|
||||||
await commands.run("pwd", timeout=2.5)
|
|
||||||
assert exc_info.value.code == "timeout"
|
|
||||||
|
|
||||||
asyncio.run(scenario())
|
|
||||||
|
|
||||||
|
|
||||||
def test_commands_map_http_request_error_to_shell_provider_error() -> None:
|
|
||||||
request = httpx.Request("POST", "http://shellctl.example/v1/jobs/run")
|
|
||||||
client = FakeShellctlClient(
|
|
||||||
wait_handler=lambda job_id, offset, timeout: (_ for _ in ()).throw(
|
|
||||||
httpx.ConnectError("connection failed", request=request)
|
|
||||||
)
|
)
|
||||||
)
|
with pytest.raises(ShellProviderError) as missing_error:
|
||||||
|
await missing_commands.run("pwd", timeout=2.5)
|
||||||
async def scenario() -> None:
|
assert missing_error.value.code == "sandbox_not_found"
|
||||||
commands = ShellctlCommands(_client_protocol(client))
|
assert missing_error.value.status_code == 404
|
||||||
with pytest.raises(ShellProviderError, match="connection failed") as exc_info:
|
|
||||||
await commands.wait("run-job", offset=3, timeout=4.0)
|
|
||||||
assert exc_info.value.code == "request_error"
|
|
||||||
|
|
||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
def test_commands_preserve_shellctl_structured_error_fields() -> None:
|
def test_delete_treats_missing_job_as_already_deleted() -> None:
|
||||||
client = FakeShellctlClient(
|
client = _Client(delete_error=ShellctlClientError(404, "job_not_found", "missing"))
|
||||||
run_handler=lambda script, cwd, env, timeout: (_ for _ in ()).throw(
|
|
||||||
ShellctlClientError(404, "sandbox_not_found", "sandbox expired")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def scenario() -> None:
|
async def scenario() -> None:
|
||||||
commands = ShellctlCommands(_client_protocol(client))
|
commands = ShellctlCommands(_client(client))
|
||||||
with pytest.raises(ShellProviderError, match="sandbox expired") as exc_info:
|
await commands.delete("job-1", force=True)
|
||||||
await commands.run("pwd", timeout=2.5)
|
|
||||||
assert exc_info.value.status_code == 404
|
|
||||||
assert exc_info.value.code == "sandbox_not_found"
|
|
||||||
|
|
||||||
asyncio.run(scenario())
|
|
||||||
|
|
||||||
|
|
||||||
def test_read_bytes_maps_oversize_payload_to_domain_error() -> None:
|
|
||||||
payload = base64.b64encode(b'{"path":"reports/large.bin","size":6,"too_large":true}').decode("ascii")
|
|
||||||
client = FakeShellctlClient(
|
|
||||||
run_handler=lambda script, cwd, env, timeout: _Job(
|
|
||||||
job_id="read-job",
|
|
||||||
status="exited",
|
|
||||||
done=True,
|
|
||||||
exit_code=0,
|
|
||||||
output=f"{shellctl._WORKSPACE_PAYLOAD_BEGIN}{payload}{shellctl._WORKSPACE_PAYLOAD_END}",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def scenario() -> None:
|
|
||||||
files = ShellctlFileTransfer(_client_protocol(client), cwd="/workspace", home_dir="/home/dify")
|
|
||||||
with pytest.raises(WorkspaceFileTooLargeError) as exc_info:
|
|
||||||
await files.read_bytes(path="reports/large.bin", max_bytes=5)
|
|
||||||
assert exc_info.value.size == 6
|
|
||||||
assert exc_info.value.max_bytes == 5
|
|
||||||
|
|
||||||
asyncio.run(scenario())
|
|
||||||
|
|
||||||
|
|
||||||
def test_file_operations_use_runtime_lease_namespace() -> None:
|
|
||||||
payload = base64.b64encode(b'{"path":"/homes/binding-b/report.txt","size":2,"content_base64":"b2s="}').decode(
|
|
||||||
"ascii"
|
|
||||||
)
|
|
||||||
client = FakeShellctlClient(
|
|
||||||
run_handler=lambda script, cwd, env, timeout: _Job(
|
|
||||||
job_id="read-job",
|
|
||||||
status="exited",
|
|
||||||
done=True,
|
|
||||||
exit_code=0,
|
|
||||||
output=f"{shellctl._WORKSPACE_PAYLOAD_BEGIN}{payload}{shellctl._WORKSPACE_PAYLOAD_END}",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def scenario() -> None:
|
|
||||||
files = ShellctlFileTransfer(
|
|
||||||
_client_protocol(client),
|
|
||||||
cwd="/workspaces/shared",
|
|
||||||
home_dir="/homes/binding-b",
|
|
||||||
)
|
|
||||||
result = await files.read_bytes(path="~/report.txt", max_bytes=10)
|
|
||||||
assert result.content == b"ok"
|
|
||||||
with pytest.raises(ValueError, match="outside this RuntimeLease"):
|
|
||||||
await files.download(remote_path="secret", cwd="/homes/binding-a")
|
|
||||||
|
|
||||||
asyncio.run(scenario())
|
|
||||||
|
|
||||||
assert client.run_calls[0].cwd == "/workspaces/shared"
|
|
||||||
assert client.run_calls[0].env == {"HOME": "/homes/binding-b"}
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_maps_http_timeout_to_shell_provider_error() -> None:
|
|
||||||
request = httpx.Request("DELETE", "http://shellctl.example/v1/jobs/run-job")
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class DeleteTimeoutClient(FakeShellctlClient):
|
|
||||||
async def delete(self, job_id, *, force=False, grace_seconds=None):
|
|
||||||
self.delete_calls.append((job_id, force, grace_seconds))
|
|
||||||
raise httpx.ReadTimeout("delete timed out", request=request)
|
|
||||||
|
|
||||||
client = DeleteTimeoutClient()
|
|
||||||
|
|
||||||
async def scenario() -> None:
|
|
||||||
commands = ShellctlCommands(_client_protocol(client))
|
|
||||||
with pytest.raises(ShellProviderError, match="delete timed out") as exc_info:
|
|
||||||
await commands.delete("run-job", force=True, grace_seconds=2.0)
|
|
||||||
assert exc_info.value.code == "timeout"
|
|
||||||
|
|
||||||
asyncio.run(scenario())
|
|
||||||
assert client.delete_calls == [("run-job", True, 2.0)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_maps_http_request_error_to_shell_provider_error() -> None:
|
|
||||||
request = httpx.Request("DELETE", "http://shellctl.example/v1/jobs/run-job")
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class DeleteRequestErrorClient(FakeShellctlClient):
|
|
||||||
async def delete(self, job_id, *, force=False, grace_seconds=None):
|
|
||||||
self.delete_calls.append((job_id, force, grace_seconds))
|
|
||||||
raise httpx.ConnectError("delete connection failed", request=request)
|
|
||||||
|
|
||||||
client = DeleteRequestErrorClient()
|
|
||||||
|
|
||||||
async def scenario() -> None:
|
|
||||||
commands = ShellctlCommands(_client_protocol(client))
|
|
||||||
with pytest.raises(ShellProviderError, match="delete connection failed") as exc_info:
|
|
||||||
await commands.delete("run-job", force=True, grace_seconds=2.0)
|
|
||||||
assert exc_info.value.code == "request_error"
|
|
||||||
|
|
||||||
asyncio.run(scenario())
|
|
||||||
assert client.delete_calls == [("run-job", True, 2.0)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_files_upload_and_download_still_work() -> None:
|
|
||||||
content = b"hello \x00 world"
|
|
||||||
encoded = base64.b64encode(content).decode("ascii")
|
|
||||||
client = FakeShellctlClient(
|
|
||||||
run_handler=lambda script, cwd, env, timeout: (
|
|
||||||
_Job(job_id="ul-job", status="exited", done=True, exit_code=0)
|
|
||||||
if "base64 -d" in script
|
|
||||||
else _Job(
|
|
||||||
job_id="dl-job",
|
|
||||||
status="exited",
|
|
||||||
done=True,
|
|
||||||
exit_code=0,
|
|
||||||
output=f"noise{shellctl._TRANSFER_BEGIN}{encoded}{shellctl._TRANSFER_END}tail",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def scenario() -> None:
|
|
||||||
files = ShellctlFileTransfer(_client_protocol(client))
|
|
||||||
await files.upload(content=content, remote_path="out.bin", cwd="~/workspace/abc12ff")
|
|
||||||
downloaded = await files.download(remote_path="report.txt", cwd="~/workspace/abc12ff")
|
|
||||||
assert downloaded == content
|
|
||||||
|
|
||||||
asyncio.run(scenario())
|
|
||||||
|
|
||||||
|
|
||||||
def test_file_transfer_timeout_is_an_end_to_end_budget(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
clock = {"value": 100.0}
|
|
||||||
|
|
||||||
def fake_monotonic() -> float:
|
|
||||||
return clock["value"]
|
|
||||||
|
|
||||||
monkeypatch.setattr(shellctl.time, "monotonic", fake_monotonic)
|
|
||||||
|
|
||||||
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None, timeout: float) -> _Job:
|
|
||||||
del script, cwd, env
|
|
||||||
assert timeout == pytest.approx(5.0, rel=0, abs=0.01)
|
|
||||||
clock["value"] = 103.5
|
|
||||||
return _Job(job_id="upload-job", status="running", done=False, output="part-1", offset=6, exit_code=None)
|
|
||||||
|
|
||||||
def wait_handler(job_id: str, offset: int, timeout: float) -> _Job:
|
|
||||||
assert job_id == "upload-job"
|
|
||||||
assert offset == 6
|
|
||||||
assert timeout == pytest.approx(1.5, rel=0, abs=0.01)
|
|
||||||
return _Job(job_id=job_id, status="exited", done=True, output="part-2", offset=12, exit_code=0)
|
|
||||||
|
|
||||||
client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler)
|
|
||||||
|
|
||||||
async def scenario() -> None:
|
|
||||||
transfer = shellctl.ShellctlFileTransfer(
|
|
||||||
client=_client_protocol(client),
|
|
||||||
timeout=5.0,
|
|
||||||
)
|
|
||||||
await transfer.upload(content=b"payload", remote_path="out.bin")
|
|
||||||
|
|
||||||
asyncio.run(scenario())
|
|
||||||
assert client.delete_calls == [("upload-job", True, None)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_file_transfer_timeout_exhaustion_raises_timeout_and_still_deletes_job(
|
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
|
||||||
) -> None:
|
|
||||||
clock = {"value": 100.0}
|
|
||||||
|
|
||||||
def fake_monotonic() -> float:
|
|
||||||
return clock["value"]
|
|
||||||
|
|
||||||
monkeypatch.setattr(shellctl.time, "monotonic", fake_monotonic)
|
|
||||||
|
|
||||||
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None, timeout: float) -> _Job:
|
|
||||||
del script, cwd, env
|
|
||||||
assert timeout == pytest.approx(5.0, rel=0, abs=0.01)
|
|
||||||
clock["value"] = 106.0
|
|
||||||
return _Job(job_id="upload-job", status="running", done=False, output="part-1", offset=6, exit_code=None)
|
|
||||||
|
|
||||||
client = FakeShellctlClient(run_handler=run_handler)
|
|
||||||
|
|
||||||
async def scenario() -> None:
|
|
||||||
transfer = shellctl.ShellctlFileTransfer(
|
|
||||||
client=_client_protocol(client),
|
|
||||||
timeout=5.0,
|
|
||||||
)
|
|
||||||
with pytest.raises(ShellProviderError, match="timed out") as exc_info:
|
|
||||||
await transfer.upload(content=b"payload", remote_path="out.bin")
|
|
||||||
assert exc_info.value.code == "timeout"
|
|
||||||
|
|
||||||
asyncio.run(scenario())
|
|
||||||
assert client.delete_calls == [("upload-job", True, None)]
|
|
||||||
|
|
||||||
|
|
||||||
def test_download_missing_file_raises() -> None:
|
|
||||||
client = FakeShellctlClient(
|
|
||||||
run_handler=lambda script, cwd, env, timeout: _Job(
|
|
||||||
job_id="dl-job",
|
|
||||||
status="exited",
|
|
||||||
done=True,
|
|
||||||
output="",
|
|
||||||
exit_code=shellctl._DOWNLOAD_MISSING_EXIT_CODE,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def scenario() -> None:
|
|
||||||
files = ShellctlFileTransfer(_client_protocol(client))
|
|
||||||
with pytest.raises(ShellFileTransferError, match="not found"):
|
|
||||||
await files.download(remote_path="missing.txt")
|
|
||||||
|
|
||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
assert client.delete_calls == [("job-1", True, None)]
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ def test_upload_request_uses_agent_inner_endpoint_and_binds_sandbox_base(monkeyp
|
|||||||
assert json.loads(request.content) == {
|
assert json.loads(request.content) == {
|
||||||
"tenant_id": "tenant-1",
|
"tenant_id": "tenant-1",
|
||||||
"user_id": "user-1",
|
"user_id": "user-1",
|
||||||
|
"user_from": "account",
|
||||||
"filename": "report.pdf",
|
"filename": "report.pdf",
|
||||||
"mimetype": "application/pdf",
|
"mimetype": "application/pdf",
|
||||||
"conversation_id": "conversation-1",
|
"conversation_id": "conversation-1",
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ from dify_agent.client import (
|
|||||||
DifyAgentValidationError,
|
DifyAgentValidationError,
|
||||||
)
|
)
|
||||||
from dify_agent.protocol import (
|
from dify_agent.protocol import (
|
||||||
|
BindingFileDownloadRequest,
|
||||||
|
BindingFileDownloadResponse,
|
||||||
|
BindingFileListResponse,
|
||||||
|
BindingFileReadResponse,
|
||||||
CancelRunRequest,
|
CancelRunRequest,
|
||||||
CancelRunResponse,
|
CancelRunResponse,
|
||||||
CreateExecutionBindingRequest,
|
CreateExecutionBindingRequest,
|
||||||
@@ -37,10 +41,6 @@ from dify_agent.protocol import (
|
|||||||
RunStartedEvent,
|
RunStartedEvent,
|
||||||
RunSucceededEvent,
|
RunSucceededEvent,
|
||||||
RunSucceededEventData,
|
RunSucceededEventData,
|
||||||
WorkspaceListResponse,
|
|
||||||
WorkspaceReadResponse,
|
|
||||||
WorkspaceUploadRequest,
|
|
||||||
WorkspaceUploadResponse,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -83,12 +83,13 @@ def _run_status_json(status: str) -> dict[str, object]:
|
|||||||
return {"run_id": "run-1", "status": status, "created_at": now, "updated_at": now, "error": None}
|
return {"run_id": "run-1", "status": status, "created_at": now, "updated_at": now, "error": None}
|
||||||
|
|
||||||
|
|
||||||
def _workspace_upload_request(path: str = "report.txt") -> WorkspaceUploadRequest:
|
def _binding_file_download_request(path: str = "report.txt") -> BindingFileDownloadRequest:
|
||||||
return WorkspaceUploadRequest(
|
return BindingFileDownloadRequest(
|
||||||
backend_binding_ref="binding-ref",
|
backend_binding_ref="binding-ref",
|
||||||
path=path,
|
path=path,
|
||||||
execution_context=DifyExecutionContextLayerConfig(
|
execution_context=DifyExecutionContextLayerConfig(
|
||||||
tenant_id="tenant-1",
|
tenant_id="tenant-1",
|
||||||
|
user_id="account-1",
|
||||||
user_from="account",
|
user_from="account",
|
||||||
agent_mode="agent_app",
|
agent_mode="agent_app",
|
||||||
invoke_from="debugger",
|
invoke_from="debugger",
|
||||||
@@ -96,6 +97,11 @@ def _workspace_upload_request(path: str = "report.txt") -> WorkspaceUploadReques
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_binding_download_timeout(request: httpx.Request) -> None:
|
||||||
|
timeout = cast(dict[str, float], request.extensions["timeout"])
|
||||||
|
assert timeout == {"connect": 90.0, "read": 90.0, "write": 90.0, "pool": 90.0}
|
||||||
|
|
||||||
|
|
||||||
def _function_tool_result_payload(key: str) -> dict[str, object]:
|
def _function_tool_result_payload(key: str) -> dict[str, object]:
|
||||||
return {
|
return {
|
||||||
"type": "pydantic_ai_event",
|
"type": "pydantic_ai_event",
|
||||||
@@ -239,85 +245,70 @@ def test_async_methods_and_wait_run_parse_protocol_dtos() -> None:
|
|||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
def test_sync_workspace_methods_post_dtos_and_parse_responses() -> None:
|
def test_sync_binding_file_methods_post_dtos_and_parse_responses() -> None:
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
if request.url.path == "/workspace/files/list":
|
if request.url.path == "/execution-bindings/files/list":
|
||||||
payload = cast(dict[str, object], json.loads(request.content))
|
payload = cast(dict[str, object], json.loads(request.content))
|
||||||
assert payload["path"] == "."
|
assert payload["path"] == "."
|
||||||
assert payload["backend_binding_ref"] == "binding-ref"
|
assert payload["backend_binding_ref"] == "binding-ref"
|
||||||
return httpx.Response(200, json={"path": ".", "entries": [], "truncated": False})
|
return httpx.Response(200, json={"path": ".", "entries": [], "truncated": False})
|
||||||
if request.url.path == "/workspace/files/read":
|
if request.url.path == "/execution-bindings/files/read":
|
||||||
payload = cast(dict[str, object], json.loads(request.content))
|
payload = cast(dict[str, object], json.loads(request.content))
|
||||||
assert payload["path"] == "note.txt"
|
assert payload["path"] == "note.txt"
|
||||||
assert payload["max_bytes"] == 128
|
assert payload["max_bytes"] == 128
|
||||||
return httpx.Response(
|
return httpx.Response(
|
||||||
200, json={"path": "note.txt", "size": 5, "truncated": False, "binary": False, "text": "hello"}
|
200, json={"path": "note.txt", "size": 5, "truncated": False, "binary": False, "text": "hello"}
|
||||||
)
|
)
|
||||||
if request.url.path == "/workspace/files/upload":
|
if request.url.path == "/execution-bindings/files/download":
|
||||||
|
_assert_binding_download_timeout(request)
|
||||||
payload = cast(dict[str, object], json.loads(request.content))
|
payload = cast(dict[str, object], json.loads(request.content))
|
||||||
assert payload["path"] == "report.txt"
|
assert payload["path"] == "report.txt"
|
||||||
return httpx.Response(
|
return httpx.Response(
|
||||||
200,
|
200,
|
||||||
json={
|
json={"reference": "dify-file-ref:file-1"},
|
||||||
"path": "report.txt",
|
|
||||||
"file": {
|
|
||||||
"transfer_method": "tool_file",
|
|
||||||
"reference": "dify-file-ref:file-1",
|
|
||||||
"download_url": "https://files.example.com/report.txt",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
||||||
|
|
||||||
client = Client(base_url="http://testserver", sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)))
|
client = Client(base_url="http://testserver", sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)))
|
||||||
|
|
||||||
listing = client.list_workspace_files_sync("binding-ref", ".")
|
listing = client.list_binding_files_sync("binding-ref", ".")
|
||||||
preview = client.read_workspace_file_sync("binding-ref", "note.txt", max_bytes=128)
|
preview = client.read_binding_file_sync("binding-ref", "note.txt", max_bytes=128)
|
||||||
uploaded = client.upload_workspace_file_sync(_workspace_upload_request())
|
downloaded = client.download_binding_file_sync(_binding_file_download_request())
|
||||||
|
|
||||||
assert isinstance(listing, WorkspaceListResponse)
|
assert isinstance(listing, BindingFileListResponse)
|
||||||
assert listing.path == "."
|
assert listing.path == "."
|
||||||
assert isinstance(preview, WorkspaceReadResponse)
|
assert isinstance(preview, BindingFileReadResponse)
|
||||||
assert preview.text == "hello"
|
assert preview.text == "hello"
|
||||||
assert isinstance(uploaded, WorkspaceUploadResponse)
|
assert isinstance(downloaded, BindingFileDownloadResponse)
|
||||||
assert uploaded.file.reference == "dify-file-ref:file-1"
|
assert downloaded.reference == "dify-file-ref:file-1"
|
||||||
assert uploaded.file.download_url == "https://files.example.com/report.txt"
|
|
||||||
|
|
||||||
|
|
||||||
def test_async_workspace_methods_post_dtos_and_parse_responses() -> None:
|
def test_async_binding_file_methods_post_dtos_and_parse_responses() -> None:
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
if request.url.path == "/workspace/files/list":
|
if request.url.path == "/execution-bindings/files/list":
|
||||||
return httpx.Response(200, json={"path": ".", "entries": [], "truncated": False})
|
return httpx.Response(200, json={"path": ".", "entries": [], "truncated": False})
|
||||||
if request.url.path == "/workspace/files/read":
|
if request.url.path == "/execution-bindings/files/read":
|
||||||
|
payload = cast(dict[str, object], json.loads(request.content))
|
||||||
|
assert payload["max_bytes"] == 262144
|
||||||
return httpx.Response(
|
return httpx.Response(
|
||||||
200, json={"path": "note.txt", "size": 5, "truncated": False, "binary": False, "text": "hello"}
|
200, json={"path": "note.txt", "size": 5, "truncated": False, "binary": False, "text": "hello"}
|
||||||
)
|
)
|
||||||
if request.url.path == "/workspace/files/upload":
|
if request.url.path == "/execution-bindings/files/download":
|
||||||
return httpx.Response(
|
_assert_binding_download_timeout(request)
|
||||||
200,
|
return httpx.Response(200, json={"reference": "dify-file-ref:file-1"})
|
||||||
json={
|
|
||||||
"path": "report.txt",
|
|
||||||
"file": {
|
|
||||||
"transfer_method": "tool_file",
|
|
||||||
"reference": "dify-file-ref:file-1",
|
|
||||||
"download_url": "https://files.example.com/report.txt",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
||||||
|
|
||||||
async def scenario() -> None:
|
async def scenario() -> None:
|
||||||
http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
http_client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
|
||||||
client = Client(base_url="http://testserver", async_http_client=http_client)
|
client = Client(base_url="http://testserver", async_http_client=http_client)
|
||||||
|
|
||||||
listing = await client.list_workspace_files("binding-ref", ".")
|
listing = await client.list_binding_files("binding-ref", ".")
|
||||||
preview = await client.read_workspace_file("binding-ref", "note.txt")
|
preview = await client.read_binding_file("binding-ref", "note.txt")
|
||||||
uploaded = await client.upload_workspace_file(_workspace_upload_request())
|
downloaded = await client.download_binding_file(_binding_file_download_request())
|
||||||
|
|
||||||
assert listing.path == "."
|
assert listing.path == "."
|
||||||
assert preview.text == "hello"
|
assert preview.text == "hello"
|
||||||
assert uploaded.file.reference == "dify-file-ref:file-1"
|
assert downloaded.reference == "dify-file-ref:file-1"
|
||||||
assert uploaded.file.download_url == "https://files.example.com/report.txt"
|
|
||||||
await http_client.aclose()
|
await http_client.aclose()
|
||||||
|
|
||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
@@ -445,28 +436,22 @@ def test_home_snapshot_client_maps_sync_validation_and_async_http_errors() -> No
|
|||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
def test_sync_upload_workspace_file_rejects_missing_download_url() -> None:
|
def test_sync_download_binding_file_rejects_missing_reference() -> None:
|
||||||
def handler(request: httpx.Request) -> httpx.Response:
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
if request.url.path != "/workspace/files/upload":
|
if request.url.path != "/execution-bindings/files/download":
|
||||||
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
raise AssertionError(f"unexpected request: {request.method} {request.url}")
|
||||||
return httpx.Response(
|
return httpx.Response(
|
||||||
200,
|
200,
|
||||||
json={
|
json={},
|
||||||
"path": "report.txt",
|
|
||||||
"file": {
|
|
||||||
"transfer_method": "tool_file",
|
|
||||||
"reference": "dify-file-ref:file-1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
client = Client(base_url="http://testserver", sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)))
|
client = Client(base_url="http://testserver", sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)))
|
||||||
|
|
||||||
with pytest.raises(DifyAgentValidationError):
|
with pytest.raises(DifyAgentValidationError):
|
||||||
_ = client.upload_workspace_file_sync(_workspace_upload_request())
|
_ = client.download_binding_file_sync(_binding_file_download_request())
|
||||||
|
|
||||||
|
|
||||||
def test_sync_workspace_methods_map_invalid_json_to_validation_error() -> None:
|
def test_sync_binding_file_methods_map_invalid_json_to_validation_error() -> None:
|
||||||
responses = iter([httpx.Response(200, text="not-json"), httpx.Response(404, json={"detail": "missing"})])
|
responses = iter([httpx.Response(200, text="not-json"), httpx.Response(404, json={"detail": "missing"})])
|
||||||
|
|
||||||
def handler(_request: httpx.Request) -> httpx.Response:
|
def handler(_request: httpx.Request) -> httpx.Response:
|
||||||
@@ -475,10 +460,10 @@ def test_sync_workspace_methods_map_invalid_json_to_validation_error() -> None:
|
|||||||
client = Client(base_url="http://testserver", sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)))
|
client = Client(base_url="http://testserver", sync_http_client=httpx.Client(transport=httpx.MockTransport(handler)))
|
||||||
|
|
||||||
with pytest.raises(DifyAgentValidationError):
|
with pytest.raises(DifyAgentValidationError):
|
||||||
_ = client.list_workspace_files_sync("binding-ref", ".")
|
_ = client.list_binding_files_sync("binding-ref", ".")
|
||||||
|
|
||||||
with pytest.raises(DifyAgentHTTPError) as http_error:
|
with pytest.raises(DifyAgentHTTPError) as http_error:
|
||||||
_ = client.read_workspace_file_sync("binding-ref", "missing.txt")
|
_ = client.read_binding_file_sync("binding-ref", "missing.txt")
|
||||||
assert http_error.value.status_code == 404
|
assert http_error.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from typing import cast
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
import dify_agent.layers.shell.layer as shell_layer_module
|
import dify_agent.layers.shell.layer as shell_layer_module
|
||||||
|
import dify_agent.runtime.command_runner as command_runner_module
|
||||||
from dify_agent.layers.shell import (
|
from dify_agent.layers.shell import (
|
||||||
DIFY_SHELL_LAYER_TYPE_ID,
|
DIFY_SHELL_LAYER_TYPE_ID,
|
||||||
DifyShellCliToolConfig,
|
DifyShellCliToolConfig,
|
||||||
@@ -25,7 +26,6 @@ from dify_agent.layers.shell.layer import (
|
|||||||
from dify_agent.adapters.shell.protocols import (
|
from dify_agent.adapters.shell.protocols import (
|
||||||
ShellCommandResult,
|
ShellCommandResult,
|
||||||
ShellCommandStatus,
|
ShellCommandStatus,
|
||||||
ShellFileTransferProtocol,
|
|
||||||
ShellProviderError,
|
ShellProviderError,
|
||||||
)
|
)
|
||||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||||
@@ -36,9 +36,6 @@ from dify_agent.runtime_backend import (
|
|||||||
ExecutionBindingBackend,
|
ExecutionBindingBackend,
|
||||||
RuntimeLayout,
|
RuntimeLayout,
|
||||||
RuntimeLease,
|
RuntimeLease,
|
||||||
WorkspaceFileContent,
|
|
||||||
WorkspaceListResult,
|
|
||||||
WorkspaceReadResult,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -155,24 +152,6 @@ class _UnexpectedToolError(Exception):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class FakeFiles(ShellFileTransferProtocol):
|
|
||||||
async def list_directory(self, *, path: str, limit: int) -> WorkspaceListResult:
|
|
||||||
raise AssertionError("resource.files should not be used by shell layer logic")
|
|
||||||
|
|
||||||
async def read_file(self, *, path: str, max_bytes: int) -> WorkspaceReadResult:
|
|
||||||
raise AssertionError("resource.files should not be used by shell layer logic")
|
|
||||||
|
|
||||||
async def read_bytes(self, *, path: str, max_bytes: int) -> WorkspaceFileContent:
|
|
||||||
del max_bytes
|
|
||||||
raise AssertionError("resource.files should not be used by shell layer logic")
|
|
||||||
|
|
||||||
async def upload(self, *, content: bytes, remote_path: str, cwd: str | None = None) -> None:
|
|
||||||
raise AssertionError("resource.files should not be used by production shell layer logic")
|
|
||||||
|
|
||||||
async def download(self, *, remote_path: str, cwd: str | None = None) -> bytes:
|
|
||||||
raise AssertionError("resource.files should not be used by production shell layer logic")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class FakeCommands:
|
class FakeCommands:
|
||||||
run_handler: Callable[[str, str | None, Mapping[str, str] | None, float], ShellCommandResult] | None = None
|
run_handler: Callable[[str, str | None, Mapping[str, str] | None, float], ShellCommandResult] | None = None
|
||||||
@@ -233,7 +212,6 @@ class FakeCommands:
|
|||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class FakeResource:
|
class FakeResource:
|
||||||
commands: FakeCommands
|
commands: FakeCommands
|
||||||
files: FakeFiles = field(default_factory=FakeFiles)
|
|
||||||
handle: str = "sandbox-1"
|
handle: str = "sandbox-1"
|
||||||
layout: RuntimeLayout = field(
|
layout: RuntimeLayout = field(
|
||||||
default_factory=lambda: RuntimeLayout(
|
default_factory=lambda: RuntimeLayout(
|
||||||
@@ -1099,7 +1077,7 @@ def test_run_remote_script_complete_returns_incomplete_reason_for_timeout(
|
|||||||
def fake_monotonic() -> float:
|
def fake_monotonic() -> float:
|
||||||
return now
|
return now
|
||||||
|
|
||||||
monkeypatch.setattr(shell_layer_module.time, "monotonic", fake_monotonic)
|
monkeypatch.setattr(command_runner_module.time, "monotonic", fake_monotonic)
|
||||||
|
|
||||||
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult:
|
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> ShellCommandResult:
|
||||||
nonlocal now
|
nonlocal now
|
||||||
|
|||||||
@@ -27,15 +27,17 @@ class _Backend:
|
|||||||
async def test_runtime_layer_acquires_and_releases_operation_scoped_lease() -> None:
|
async def test_runtime_layer_acquires_and_releases_operation_scoped_lease() -> None:
|
||||||
lease = cast(
|
lease = cast(
|
||||||
RuntimeLease,
|
RuntimeLease,
|
||||||
type(
|
cast(
|
||||||
"Lease",
|
object,
|
||||||
(),
|
type(
|
||||||
{
|
"Lease",
|
||||||
"layout": RuntimeLayout(home_dir="/home/agent", workspace_dir="/workspace"),
|
(),
|
||||||
"commands": object(),
|
{
|
||||||
"files": object(),
|
"layout": RuntimeLayout(home_dir="/home/agent", workspace_dir="/workspace"),
|
||||||
},
|
"commands": object(),
|
||||||
)(),
|
},
|
||||||
|
)(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
backend = _Backend(lease=lease)
|
backend = _Backend(lease=lease)
|
||||||
layer = DifyRuntimeLayer.from_config_with_backend(
|
layer = DifyRuntimeLayer.from_config_with_backend(
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import pytest
|
|||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
from dify_agent.protocol import (
|
from dify_agent.protocol import (
|
||||||
|
BindingFileListRequest,
|
||||||
|
BindingFileReadRequest,
|
||||||
CreateExecutionBindingRequest,
|
CreateExecutionBindingRequest,
|
||||||
CreateHomeSnapshotFromBindingRequest,
|
CreateHomeSnapshotFromBindingRequest,
|
||||||
DestroyExecutionBindingRequest,
|
DestroyExecutionBindingRequest,
|
||||||
WorkspaceListRequest,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -64,7 +65,26 @@ def test_snapshot_and_file_requests_locate_binding_directly() -> None:
|
|||||||
home_snapshot_id="home-2",
|
home_snapshot_id="home-2",
|
||||||
backend_binding_ref="binding-ref",
|
backend_binding_ref="binding-ref",
|
||||||
)
|
)
|
||||||
listing = WorkspaceListRequest(backend_binding_ref="binding-ref", path="~/files")
|
listing = BindingFileListRequest(backend_binding_ref="binding-ref", path="~/files")
|
||||||
|
|
||||||
assert snapshot.backend_binding_ref == "binding-ref"
|
assert snapshot.backend_binding_ref == "binding-ref"
|
||||||
assert listing.path == "~/files"
|
assert listing.path == "~/files"
|
||||||
|
|
||||||
|
|
||||||
|
def test_binding_file_read_preview_uses_bounded_default_and_limit() -> None:
|
||||||
|
assert BindingFileReadRequest(backend_binding_ref="binding-ref", path="report.txt").max_bytes == 262144
|
||||||
|
assert (
|
||||||
|
BindingFileReadRequest(
|
||||||
|
backend_binding_ref="binding-ref",
|
||||||
|
path="report.txt",
|
||||||
|
max_bytes=262144,
|
||||||
|
).max_bytes
|
||||||
|
== 262144
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError, match="max_bytes"):
|
||||||
|
BindingFileReadRequest(
|
||||||
|
backend_binding_ref="binding-ref",
|
||||||
|
path="report.txt",
|
||||||
|
max_bytes=262145,
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from contextlib import suppress
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from dify_agent.adapters.shell.protocols import ShellCommandResult
|
||||||
|
from dify_agent.runtime.command_runner import execute_complete_with_commands
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _BlockingCommands:
|
||||||
|
wait_started: asyncio.Event = field(default_factory=asyncio.Event)
|
||||||
|
wait_forever: asyncio.Event = field(default_factory=asyncio.Event)
|
||||||
|
deletes: list[tuple[str, bool]] = field(default_factory=list)
|
||||||
|
|
||||||
|
async def run(self, script: str, *, cwd: str | None, env: dict[str, str] | None, timeout: float):
|
||||||
|
assert script == "long-running"
|
||||||
|
assert cwd == "/workspace"
|
||||||
|
assert env == {"HOME": "/home/agent"}
|
||||||
|
assert timeout > 0
|
||||||
|
return ShellCommandResult(
|
||||||
|
job_id="job-1",
|
||||||
|
status="running",
|
||||||
|
done=False,
|
||||||
|
exit_code=None,
|
||||||
|
output="started",
|
||||||
|
offset=7,
|
||||||
|
truncated=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def wait(self, job_id: str, *, offset: int, timeout: float):
|
||||||
|
assert (job_id, offset) == ("job-1", 7)
|
||||||
|
assert timeout > 0
|
||||||
|
self.wait_started.set()
|
||||||
|
await self.wait_forever.wait()
|
||||||
|
raise AssertionError("wait must remain blocked until cancellation")
|
||||||
|
|
||||||
|
async def read_output(self, job_id: str, *, offset: int):
|
||||||
|
raise AssertionError("unexpected read_output")
|
||||||
|
|
||||||
|
async def input(self, job_id: str, text: str, *, offset: int, timeout: float):
|
||||||
|
raise AssertionError("unexpected input")
|
||||||
|
|
||||||
|
async def interrupt(self, job_id: str, *, grace_seconds: float):
|
||||||
|
raise AssertionError("unexpected interrupt")
|
||||||
|
|
||||||
|
async def tail(self, job_id: str):
|
||||||
|
raise AssertionError("unexpected tail")
|
||||||
|
|
||||||
|
async def delete(self, job_id: str, *, force: bool = False, grace_seconds: float | None = None) -> None:
|
||||||
|
assert grace_seconds is None
|
||||||
|
self.deletes.append((job_id, force))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_cancellation_deletes_job_returned_before_blocking_wait() -> None:
|
||||||
|
commands = _BlockingCommands()
|
||||||
|
task = asyncio.create_task(
|
||||||
|
execute_complete_with_commands(
|
||||||
|
commands, # pyright: ignore[reportArgumentType]
|
||||||
|
"long-running",
|
||||||
|
cwd="/workspace",
|
||||||
|
env={"HOME": "/home/agent"},
|
||||||
|
timeout=60.0,
|
||||||
|
max_output_bytes=4096,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(commands.wait_started.wait(), timeout=1)
|
||||||
|
|
||||||
|
task.cancel()
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await asyncio.wait_for(task, timeout=1)
|
||||||
|
finally:
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await asyncio.wait_for(task, timeout=1)
|
||||||
|
|
||||||
|
assert commands.deletes == [("job-1", True)]
|
||||||
@@ -270,6 +270,15 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt
|
|||||||
getattr(route, "path", None) == "/agent-stub/drive/manifest" for route in create_app(settings).routes
|
getattr(route, "path", None) == "/agent-stub/drive/manifest" for route in create_app(settings).routes
|
||||||
)
|
)
|
||||||
assert any(getattr(route, "path", None) == "/agent-stub/drive/commit" for route in create_app(settings).routes)
|
assert any(getattr(route, "path", None) == "/agent-stub/drive/commit" for route in create_app(settings).routes)
|
||||||
|
route_paths = create_app(settings).openapi()["paths"]
|
||||||
|
assert {
|
||||||
|
"/execution-bindings/files/list",
|
||||||
|
"/execution-bindings/files/read",
|
||||||
|
"/execution-bindings/files/download",
|
||||||
|
}.issubset(route_paths)
|
||||||
|
assert "/workspace/files/list" not in route_paths
|
||||||
|
assert "/workspace/files/read" not in route_paths
|
||||||
|
assert "/workspace/files/upload" not in route_paths
|
||||||
|
|
||||||
assert FakeRunScheduler.created[0].shutdown_called is True
|
assert FakeRunScheduler.created[0].shutdown_called is True
|
||||||
assert FakeRunScheduler.created[0].dify_api_http_client.is_closed is True
|
assert FakeRunScheduler.created[0].dify_api_http_client.is_closed is True
|
||||||
|
|||||||
@@ -0,0 +1,765 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
from contextlib import suppress
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal, cast
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from dify_agent.adapters.shell.protocols import ShellCommandResult, ShellProviderError
|
||||||
|
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||||
|
from dify_agent.protocol import BindingFileDownloadRequest, BindingFileListRequest, BindingFileReadRequest
|
||||||
|
from dify_agent.runtime_backend import BindingAcquireError, BindingLostError, RuntimeLayout, RuntimeLease
|
||||||
|
from dify_agent.server import binding_files as binding_files_module
|
||||||
|
from dify_agent.server.binding_files import BindingFileError, BindingFileService, resolve_binding_path
|
||||||
|
from dify_agent.server.routes.binding_files import create_binding_files_router
|
||||||
|
|
||||||
|
_REFERENCE = "dify-file-ref:eyJyZWNvcmRfaWQiOiJ0b29sLTEifQ=="
|
||||||
|
|
||||||
|
|
||||||
|
def _framed(payload: dict[str, object]) -> str:
|
||||||
|
encoded = base64.b64encode(json.dumps(payload).encode()).decode()
|
||||||
|
return f"<<<DIFY_BINDING_FILE_BEGIN>>>{encoded}<<<DIFY_BINDING_FILE_END>>>"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _Commands:
|
||||||
|
outputs: list[str]
|
||||||
|
exit_codes: list[int] = field(default_factory=list)
|
||||||
|
calls: list[tuple[str, str | None, dict[str, str] | None, float]] = field(default_factory=list)
|
||||||
|
deletes: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
async def run(self, script: str, *, cwd: str | None = None, env=None, timeout: float) -> ShellCommandResult:
|
||||||
|
self.calls.append((script, cwd, env, timeout))
|
||||||
|
output = self.outputs.pop(0)
|
||||||
|
exit_code = self.exit_codes.pop(0) if self.exit_codes else 0
|
||||||
|
return ShellCommandResult(
|
||||||
|
job_id=f"job-{len(self.calls)}",
|
||||||
|
status="exited",
|
||||||
|
done=True,
|
||||||
|
exit_code=exit_code,
|
||||||
|
output=output,
|
||||||
|
offset=len(output),
|
||||||
|
truncated=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def wait(self, job_id: str, *, offset: int, timeout: float) -> ShellCommandResult:
|
||||||
|
raise AssertionError("unexpected wait")
|
||||||
|
|
||||||
|
async def read_output(self, job_id: str, *, offset: int):
|
||||||
|
raise AssertionError("unexpected read_output")
|
||||||
|
|
||||||
|
async def input(self, job_id: str, text: str, *, offset: int, timeout: float):
|
||||||
|
raise AssertionError("unexpected input")
|
||||||
|
|
||||||
|
async def interrupt(self, job_id: str, *, grace_seconds: float):
|
||||||
|
raise AssertionError("unexpected interrupt")
|
||||||
|
|
||||||
|
async def tail(self, job_id: str):
|
||||||
|
raise AssertionError("unexpected tail")
|
||||||
|
|
||||||
|
async def delete(self, job_id: str, *, force: bool = False, grace_seconds: float | None = None) -> None:
|
||||||
|
assert force is True
|
||||||
|
self.deletes.append(job_id)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _ProviderErrorCommands(_Commands):
|
||||||
|
phase: Literal["run", "wait"] = "run"
|
||||||
|
error_code: str = "timeout"
|
||||||
|
|
||||||
|
async def run(self, script: str, *, cwd: str | None = None, env=None, timeout: float) -> ShellCommandResult:
|
||||||
|
self.calls.append((script, cwd, env, timeout))
|
||||||
|
if self.phase == "run":
|
||||||
|
raise ShellProviderError("shell provider failed", code=self.error_code)
|
||||||
|
return ShellCommandResult(
|
||||||
|
job_id="job-1",
|
||||||
|
status="running",
|
||||||
|
done=False,
|
||||||
|
exit_code=None,
|
||||||
|
output="",
|
||||||
|
offset=0,
|
||||||
|
truncated=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def wait(self, job_id: str, *, offset: int, timeout: float) -> ShellCommandResult:
|
||||||
|
assert (job_id, offset) == ("job-1", 0)
|
||||||
|
assert timeout > 0
|
||||||
|
raise ShellProviderError("shell provider failed", code=self.error_code)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _LocalCommands(_Commands):
|
||||||
|
async def run(self, script: str, *, cwd: str | None = None, env=None, timeout: float) -> ShellCommandResult:
|
||||||
|
self.calls.append((script, cwd, env, timeout))
|
||||||
|
process = await asyncio.create_subprocess_shell(
|
||||||
|
script,
|
||||||
|
cwd=cwd,
|
||||||
|
env={**os.environ, **(env or {})},
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
output, _ = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
||||||
|
text = output.decode(errors="replace")
|
||||||
|
return ShellCommandResult(
|
||||||
|
job_id=f"local-job-{len(self.calls)}",
|
||||||
|
status="exited",
|
||||||
|
done=True,
|
||||||
|
exit_code=process.returncode,
|
||||||
|
output=text,
|
||||||
|
offset=len(text),
|
||||||
|
truncated=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _Lease:
|
||||||
|
commands: _Commands
|
||||||
|
layout: RuntimeLayout = RuntimeLayout(home_dir="/home/agent", workspace_dir="/workspace")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class _Backend:
|
||||||
|
lease: RuntimeLease
|
||||||
|
acquired: list[str] = field(default_factory=list)
|
||||||
|
releases: int = 0
|
||||||
|
|
||||||
|
async def acquire(self, binding_ref: str) -> RuntimeLease:
|
||||||
|
self.acquired.append(binding_ref)
|
||||||
|
return self.lease
|
||||||
|
|
||||||
|
async def release(self, lease: RuntimeLease) -> None:
|
||||||
|
assert lease is self.lease
|
||||||
|
self.releases += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _context() -> DifyExecutionContextLayerConfig:
|
||||||
|
return DifyExecutionContextLayerConfig(
|
||||||
|
tenant_id="tenant-1",
|
||||||
|
user_id="account-1",
|
||||||
|
user_from="account",
|
||||||
|
agent_mode="agent_app",
|
||||||
|
invoke_from="debugger",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _service(commands: _Commands, *, configured: bool = True) -> tuple[BindingFileService, _Backend]:
|
||||||
|
backend = _Backend(lease=cast(RuntimeLease, _Lease(commands=commands)))
|
||||||
|
service = BindingFileService(
|
||||||
|
execution_bindings=backend, # pyright: ignore[reportArgumentType]
|
||||||
|
agent_stub_api_base_url="http://stub/agent-stub" if configured else None,
|
||||||
|
agent_stub_token_factory=(lambda execution_context, *, session_id: "secret-jwe") if configured else None,
|
||||||
|
)
|
||||||
|
return service, backend
|
||||||
|
|
||||||
|
|
||||||
|
def _local_service(tmp_path: Path) -> tuple[BindingFileService, _Backend, _LocalCommands, Path, Path]:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
home = tmp_path / "home"
|
||||||
|
workspace.mkdir()
|
||||||
|
home.mkdir()
|
||||||
|
commands = _LocalCommands(outputs=[])
|
||||||
|
backend = _Backend(
|
||||||
|
lease=cast(
|
||||||
|
RuntimeLease,
|
||||||
|
_Lease(
|
||||||
|
commands=commands,
|
||||||
|
layout=RuntimeLayout(home_dir=str(home), workspace_dir=str(workspace)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
service = BindingFileService(
|
||||||
|
execution_bindings=backend, # pyright: ignore[reportArgumentType]
|
||||||
|
agent_stub_api_base_url=None,
|
||||||
|
agent_stub_token_factory=None,
|
||||||
|
)
|
||||||
|
return service, backend, commands, workspace, home
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_binding_path_supports_workspace_home_absolute_and_parent_paths() -> None:
|
||||||
|
layout = RuntimeLayout(home_dir="/home/agent", workspace_dir="/workspace")
|
||||||
|
|
||||||
|
assert resolve_binding_path("", layout) == "/workspace"
|
||||||
|
assert resolve_binding_path("reports/out.csv", layout) == "/workspace/reports/out.csv"
|
||||||
|
assert resolve_binding_path("~", layout) == "/home/agent"
|
||||||
|
assert resolve_binding_path("~/outputs/out.csv", layout) == "/home/agent/outputs/out.csv"
|
||||||
|
assert resolve_binding_path("/var/data/out.csv", layout) == "/var/data/out.csv"
|
||||||
|
assert resolve_binding_path("../shared/out.csv", layout) == "/shared/out.csv"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_list_and_read_use_commands_with_consistent_binding_paths_and_release_leases() -> None:
|
||||||
|
commands = _Commands(
|
||||||
|
outputs=[
|
||||||
|
_framed(
|
||||||
|
{
|
||||||
|
"path": "reports",
|
||||||
|
"entries": [{"name": "note.txt", "type": "file", "size": 4, "mtime": 1}],
|
||||||
|
"truncated": False,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
_framed(
|
||||||
|
{
|
||||||
|
"path": "~/note.txt",
|
||||||
|
"size": 4,
|
||||||
|
"truncated": False,
|
||||||
|
"binary": False,
|
||||||
|
"text": "note",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
service, backend = _service(commands)
|
||||||
|
|
||||||
|
listing = await service.list_files(BindingFileListRequest(backend_binding_ref="binding-ref", path="reports"))
|
||||||
|
preview = await service.read_file(BindingFileReadRequest(backend_binding_ref="binding-ref", path="~/note.txt"))
|
||||||
|
|
||||||
|
assert listing.entries[0].name == "note.txt"
|
||||||
|
assert preview.text == "note"
|
||||||
|
assert "/workspace/reports" in commands.calls[0][0]
|
||||||
|
assert "/home/agent/note.txt" in commands.calls[1][0]
|
||||||
|
assert all(call[1] == "/workspace" for call in commands.calls)
|
||||||
|
assert all(call[2] == {"HOME": "/home/agent"} for call in commands.calls)
|
||||||
|
assert backend.acquired == ["binding-ref", "binding-ref"]
|
||||||
|
assert backend.releases == 2
|
||||||
|
assert commands.deletes == ["job-1", "job-2"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_real_list_script_caps_1001_entries_at_1000(tmp_path: Path) -> None:
|
||||||
|
service, backend, commands, workspace, _ = _local_service(tmp_path)
|
||||||
|
for index in range(1001):
|
||||||
|
(workspace / f"{index:04d}.txt").write_bytes(b"x")
|
||||||
|
|
||||||
|
listing = await service.list_files(BindingFileListRequest(backend_binding_ref="binding-ref", path="."))
|
||||||
|
|
||||||
|
assert len(listing.entries) == 1000
|
||||||
|
assert listing.entries[0].name == "0000.txt"
|
||||||
|
assert listing.entries[-1].name == "0999.txt"
|
||||||
|
assert listing.truncated is True
|
||||||
|
assert commands.deletes == ["local-job-1"]
|
||||||
|
assert backend.releases == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_real_read_script_handles_boundary_truncation_and_binary(tmp_path: Path) -> None:
|
||||||
|
service, backend, commands, workspace, _ = _local_service(tmp_path)
|
||||||
|
(workspace / "boundary.txt").write_bytes(b"a" * 262144)
|
||||||
|
(workspace / "truncated.txt").write_bytes(b"b" * 262145)
|
||||||
|
(workspace / "binary.bin").write_bytes(b"\xff\x00")
|
||||||
|
|
||||||
|
boundary = await service.read_file(
|
||||||
|
BindingFileReadRequest(backend_binding_ref="binding-ref", path="boundary.txt", max_bytes=262144)
|
||||||
|
)
|
||||||
|
truncated = await service.read_file(
|
||||||
|
BindingFileReadRequest(backend_binding_ref="binding-ref", path="truncated.txt", max_bytes=262144)
|
||||||
|
)
|
||||||
|
binary = await service.read_file(
|
||||||
|
BindingFileReadRequest(backend_binding_ref="binding-ref", path="binary.bin", max_bytes=262144)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert boundary.size == 262144
|
||||||
|
assert boundary.truncated is False
|
||||||
|
assert boundary.binary is False
|
||||||
|
assert boundary.text == "a" * 262144
|
||||||
|
assert truncated.size == 262145
|
||||||
|
assert truncated.truncated is True
|
||||||
|
assert truncated.binary is False
|
||||||
|
assert truncated.text == "b" * 262144
|
||||||
|
assert binary.size == 2
|
||||||
|
assert binary.truncated is False
|
||||||
|
assert binary.binary is True
|
||||||
|
assert binary.text is None
|
||||||
|
assert commands.deletes == ["local-job-1", "local-job-2", "local-job-3"]
|
||||||
|
assert backend.releases == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_real_browse_script_output_over_command_cap_normalizes_to_unavailable(
|
||||||
|
tmp_path: Path,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
service, backend, commands, workspace, _ = _local_service(tmp_path)
|
||||||
|
(workspace / "report.txt").write_bytes(b"report")
|
||||||
|
monkeypatch.setattr(binding_files_module, "_BROWSE_OUTPUT_MAX_BYTES", 64)
|
||||||
|
|
||||||
|
with pytest.raises(BindingFileError) as exc_info:
|
||||||
|
await service.list_files(BindingFileListRequest(backend_binding_ref="binding-ref", path="."))
|
||||||
|
|
||||||
|
assert exc_info.value.code == "binding_unavailable"
|
||||||
|
assert exc_info.value.status_code == 502
|
||||||
|
assert commands.deletes == ["local-job-1"]
|
||||||
|
assert backend.releases == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("operation", ["list", "read"])
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_browse_preserves_binding_file_error_and_releases_lease(operation: str) -> None:
|
||||||
|
commands = _Commands(outputs=["FileNotFoundError: missing"], exit_codes=[1])
|
||||||
|
service, backend = _service(commands)
|
||||||
|
|
||||||
|
with pytest.raises(BindingFileError) as exc_info:
|
||||||
|
if operation == "list":
|
||||||
|
await service.list_files(BindingFileListRequest(backend_binding_ref="binding-ref", path="missing"))
|
||||||
|
else:
|
||||||
|
await service.read_file(BindingFileReadRequest(backend_binding_ref="binding-ref", path="missing"))
|
||||||
|
|
||||||
|
assert exc_info.value.code == "invalid_binding_path"
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
assert backend.releases == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("operation", ["list", "read"])
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_browse_maps_malformed_backend_response_to_unavailable_and_releases_lease(operation: str) -> None:
|
||||||
|
commands = _Commands(outputs=[_framed({"path": "."})])
|
||||||
|
service, backend = _service(commands)
|
||||||
|
|
||||||
|
with pytest.raises(BindingFileError) as exc_info:
|
||||||
|
if operation == "list":
|
||||||
|
await service.list_files(BindingFileListRequest(backend_binding_ref="binding-ref", path="."))
|
||||||
|
else:
|
||||||
|
await service.read_file(BindingFileReadRequest(backend_binding_ref="binding-ref", path="report.txt"))
|
||||||
|
|
||||||
|
assert exc_info.value.code == "binding_unavailable"
|
||||||
|
assert exc_info.value.status_code == 502
|
||||||
|
assert backend.releases == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("missing_field", ["user_id", "user_from"])
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_download_rejects_each_missing_identity_before_token_or_lease(missing_field: str) -> None:
|
||||||
|
context = _context().model_copy(update={missing_field: None})
|
||||||
|
request = BindingFileDownloadRequest(
|
||||||
|
backend_binding_ref="binding-ref",
|
||||||
|
path="report.txt",
|
||||||
|
execution_context=context,
|
||||||
|
)
|
||||||
|
commands = _Commands(outputs=[])
|
||||||
|
service, backend = _service(commands)
|
||||||
|
issued_tokens: list[tuple[DifyExecutionContextLayerConfig, str | None]] = []
|
||||||
|
|
||||||
|
def issue_token(execution_context: DifyExecutionContextLayerConfig, *, session_id: str | None) -> str:
|
||||||
|
issued_tokens.append((execution_context, session_id))
|
||||||
|
return "must-not-be-issued"
|
||||||
|
|
||||||
|
service.agent_stub_token_factory = issue_token
|
||||||
|
|
||||||
|
with pytest.raises(BindingFileError) as identity_error:
|
||||||
|
await service.download_file(request)
|
||||||
|
|
||||||
|
assert identity_error.value.code == "invalid_execution_context"
|
||||||
|
assert identity_error.value.status_code == 400
|
||||||
|
assert issued_tokens == []
|
||||||
|
assert backend.acquired == []
|
||||||
|
assert commands.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_download_rejects_missing_configuration_before_acquiring_lease() -> None:
|
||||||
|
request = BindingFileDownloadRequest(
|
||||||
|
backend_binding_ref="binding-ref",
|
||||||
|
path="report.txt",
|
||||||
|
execution_context=_context(),
|
||||||
|
)
|
||||||
|
commands = _Commands(outputs=[])
|
||||||
|
unavailable_service, unavailable_backend = _service(commands, configured=False)
|
||||||
|
|
||||||
|
with pytest.raises(BindingFileError) as unavailable_error:
|
||||||
|
await unavailable_service.download_file(request)
|
||||||
|
|
||||||
|
assert unavailable_error.value.code == "agent_stub_upload_unavailable"
|
||||||
|
assert unavailable_error.value.status_code == 503
|
||||||
|
assert unavailable_backend.acquired == []
|
||||||
|
assert commands.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_download_shell_quotes_resolved_path_and_returns_only_reference_inside_lease() -> None:
|
||||||
|
commands = _Commands(
|
||||||
|
outputs=[json.dumps({"transfer_method": "tool_file", "reference": _REFERENCE, "public_download_url": "bad"})]
|
||||||
|
)
|
||||||
|
service, backend = _service(commands)
|
||||||
|
issued_tokens: list[tuple[DifyExecutionContextLayerConfig, str | None]] = []
|
||||||
|
|
||||||
|
def issue_token(execution_context: DifyExecutionContextLayerConfig, *, session_id: str | None) -> str:
|
||||||
|
issued_tokens.append((execution_context, session_id))
|
||||||
|
return "secret-jwe"
|
||||||
|
|
||||||
|
service.agent_stub_token_factory = issue_token
|
||||||
|
context = _context()
|
||||||
|
|
||||||
|
result = await service.download_file(
|
||||||
|
BindingFileDownloadRequest(
|
||||||
|
backend_binding_ref="binding-ref",
|
||||||
|
path="../shared/report $(touch should-not-run); final.txt",
|
||||||
|
execution_context=context,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.reference == _REFERENCE
|
||||||
|
script, cwd, env, timeout = commands.calls[0]
|
||||||
|
assert shlex.split(script) == [
|
||||||
|
"dify-agent",
|
||||||
|
"file",
|
||||||
|
"upload",
|
||||||
|
"--no-download-link",
|
||||||
|
"/shared/report $(touch should-not-run); final.txt",
|
||||||
|
]
|
||||||
|
assert cwd == "/workspace"
|
||||||
|
assert env == {
|
||||||
|
"HOME": "/home/agent",
|
||||||
|
"DIFY_AGENT_STUB_API_BASE_URL": "http://stub/agent-stub",
|
||||||
|
"DIFY_AGENT_STUB_AUTH_JWE": "secret-jwe",
|
||||||
|
"DIFY_AGENT_STUB_DRIVE_BASE": "/mnt/drive",
|
||||||
|
}
|
||||||
|
assert timeout == pytest.approx(60.0, rel=0, abs=0.01)
|
||||||
|
assert issued_tokens == [(context, None)]
|
||||||
|
assert issued_tokens[0][0].model_dump() == context.model_dump()
|
||||||
|
assert backend.releases == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("output", "exit_code"),
|
||||||
|
[
|
||||||
|
("upload failed", 1),
|
||||||
|
("not-json", 0),
|
||||||
|
(json.dumps({"transfer_method": "url", "reference": _REFERENCE}), 0),
|
||||||
|
(json.dumps({"transfer_method": "tool_file", "reference": "raw-id"}), 0),
|
||||||
|
("x" * (32 * 1024 + 1), 0),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_download_normalizes_cli_failures_and_releases_lease(output: str, exit_code: int) -> None:
|
||||||
|
commands = _Commands(outputs=[output], exit_codes=[exit_code])
|
||||||
|
service, backend = _service(commands)
|
||||||
|
|
||||||
|
with pytest.raises(BindingFileError) as exc_info:
|
||||||
|
await service.download_file(
|
||||||
|
BindingFileDownloadRequest(
|
||||||
|
backend_binding_ref="binding-ref",
|
||||||
|
path="report.txt",
|
||||||
|
execution_context=_context(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.code == "binding_file_download_failed"
|
||||||
|
assert backend.releases == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_download_command_timeout_releases_lease_and_returns_download_failed(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
commands = _Commands(outputs=[])
|
||||||
|
service, backend = _service(commands)
|
||||||
|
|
||||||
|
async def timed_out(*_args, **_kwargs):
|
||||||
|
return type(
|
||||||
|
"TimedOutResult",
|
||||||
|
(),
|
||||||
|
{"exit_code": None, "output_complete": False, "output": "", "incomplete_reason": "timeout"},
|
||||||
|
)()
|
||||||
|
|
||||||
|
monkeypatch.setattr(binding_files_module, "execute_complete_with_commands", timed_out)
|
||||||
|
|
||||||
|
with pytest.raises(BindingFileError) as exc_info:
|
||||||
|
await service.download_file(
|
||||||
|
BindingFileDownloadRequest(
|
||||||
|
backend_binding_ref="binding-ref",
|
||||||
|
path="report.txt",
|
||||||
|
execution_context=_context(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.code == "binding_file_download_failed"
|
||||||
|
assert exc_info.value.status_code == 502
|
||||||
|
assert backend.releases == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("phase", "error_code", "expected_code", "expected_deletes"),
|
||||||
|
[
|
||||||
|
("run", "timeout", "binding_file_download_failed", []),
|
||||||
|
("wait", "timeout", "binding_file_download_failed", ["job-1"]),
|
||||||
|
("wait", "request_error", "binding_unavailable", ["job-1"]),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_download_maps_only_shell_provider_timeout_to_download_failed_and_cleans_up(
|
||||||
|
phase: Literal["run", "wait"],
|
||||||
|
error_code: str,
|
||||||
|
expected_code: str,
|
||||||
|
expected_deletes: list[str],
|
||||||
|
) -> None:
|
||||||
|
commands = _ProviderErrorCommands(outputs=[], phase=phase, error_code=error_code)
|
||||||
|
service, backend = _service(commands)
|
||||||
|
|
||||||
|
with pytest.raises(BindingFileError) as exc_info:
|
||||||
|
await service.download_file(
|
||||||
|
BindingFileDownloadRequest(
|
||||||
|
backend_binding_ref="binding-ref",
|
||||||
|
path="report.txt",
|
||||||
|
execution_context=_context(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.code == expected_code
|
||||||
|
assert exc_info.value.status_code == 502
|
||||||
|
assert commands.deletes == expected_deletes
|
||||||
|
assert backend.acquired == ["binding-ref"]
|
||||||
|
assert backend.releases == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_download_cancellation_releases_lease(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
commands = _Commands(outputs=[])
|
||||||
|
service, backend = _service(commands)
|
||||||
|
command_started = asyncio.Event()
|
||||||
|
never_finishes = asyncio.Event()
|
||||||
|
|
||||||
|
async def block(*_args, **_kwargs):
|
||||||
|
command_started.set()
|
||||||
|
await never_finishes.wait()
|
||||||
|
raise AssertionError("unreachable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(binding_files_module, "execute_complete_with_commands", block)
|
||||||
|
task = asyncio.create_task(
|
||||||
|
service.download_file(
|
||||||
|
BindingFileDownloadRequest(
|
||||||
|
backend_binding_ref="binding-ref",
|
||||||
|
path="report.txt",
|
||||||
|
execution_context=_context(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(command_started.wait(), timeout=1)
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
with pytest.raises(asyncio.CancelledError):
|
||||||
|
await asyncio.wait_for(task, timeout=1)
|
||||||
|
finally:
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
with suppress(asyncio.CancelledError):
|
||||||
|
await asyncio.wait_for(task, timeout=1)
|
||||||
|
|
||||||
|
assert backend.releases == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("backend_error", "expected_code", "expected_status"),
|
||||||
|
[
|
||||||
|
(BindingLostError("lost"), "binding_not_found", 404),
|
||||||
|
(BindingAcquireError("unavailable"), "binding_unavailable", 502),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_download_maps_binding_acquire_errors(
|
||||||
|
backend_error: Exception,
|
||||||
|
expected_code: str,
|
||||||
|
expected_status: int,
|
||||||
|
) -> None:
|
||||||
|
class FailingBackend:
|
||||||
|
releases = 0
|
||||||
|
|
||||||
|
async def acquire(self, _binding_ref: str) -> RuntimeLease:
|
||||||
|
raise backend_error
|
||||||
|
|
||||||
|
async def release(self, _lease: RuntimeLease) -> None:
|
||||||
|
self.releases += 1
|
||||||
|
|
||||||
|
backend = FailingBackend()
|
||||||
|
service = BindingFileService(
|
||||||
|
execution_bindings=backend, # pyright: ignore[reportArgumentType]
|
||||||
|
agent_stub_api_base_url="http://stub/agent-stub",
|
||||||
|
agent_stub_token_factory=lambda execution_context, *, session_id: "secret-jwe",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(BindingFileError) as exc_info:
|
||||||
|
await service.download_file(
|
||||||
|
BindingFileDownloadRequest(
|
||||||
|
backend_binding_ref="binding-ref",
|
||||||
|
path="report.txt",
|
||||||
|
execution_context=_context(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.code == expected_code
|
||||||
|
assert exc_info.value.status_code == expected_status
|
||||||
|
assert backend.releases == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("error", "expected_status", "expected_code"),
|
||||||
|
[
|
||||||
|
(BindingFileError("binding_not_found", "missing", status_code=404), 404, "binding_not_found"),
|
||||||
|
(BindingFileError("binding_unavailable", "unavailable", status_code=502), 502, "binding_unavailable"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_binding_file_route_preserves_structured_error_status_and_code(
|
||||||
|
error: BindingFileError,
|
||||||
|
expected_status: int,
|
||||||
|
expected_code: str,
|
||||||
|
) -> None:
|
||||||
|
class FailingService:
|
||||||
|
async def download_file(self, _request):
|
||||||
|
raise error
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(create_binding_files_router(lambda: cast(BindingFileService, cast(object, FailingService()))))
|
||||||
|
|
||||||
|
response = TestClient(app).post(
|
||||||
|
"/execution-bindings/files/download",
|
||||||
|
json={
|
||||||
|
"backend_binding_ref": "binding-ref",
|
||||||
|
"path": "report.txt",
|
||||||
|
"execution_context": {
|
||||||
|
"tenant_id": "tenant-1",
|
||||||
|
"user_id": "account-1",
|
||||||
|
"user_from": "account",
|
||||||
|
"agent_mode": "agent_app",
|
||||||
|
"invoke_from": "debugger",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == expected_status
|
||||||
|
assert response.json() == {"detail": {"code": expected_code, "message": error.message}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("path", "payload"),
|
||||||
|
[
|
||||||
|
("/execution-bindings/files/list", {"backend_binding_ref": "", "path": "."}),
|
||||||
|
(
|
||||||
|
"/execution-bindings/files/read",
|
||||||
|
{"backend_binding_ref": "binding-ref", "path": "report.txt", "max_bytes": 262145},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_list_and_read_route_validation_returns_structured_400_without_calling_service(
|
||||||
|
path: str,
|
||||||
|
payload: object,
|
||||||
|
) -> None:
|
||||||
|
service, backend = _service(_Commands(outputs=[]))
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(create_binding_files_router(lambda: service))
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(BindingFileService, "list_files", new_callable=AsyncMock) as list_files,
|
||||||
|
patch.object(BindingFileService, "read_file", new_callable=AsyncMock) as read_file,
|
||||||
|
):
|
||||||
|
response = TestClient(app).post(path, json=payload)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {
|
||||||
|
"detail": {
|
||||||
|
"code": "invalid_binding_path",
|
||||||
|
"message": "Binding file path or payload is invalid",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
list_files.assert_not_awaited()
|
||||||
|
read_file.assert_not_awaited()
|
||||||
|
assert backend.acquired == []
|
||||||
|
assert backend.releases == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_route_redacts_unexpected_field_value_from_validation_error() -> None:
|
||||||
|
service, backend = _service(_Commands(outputs=[]))
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(create_binding_files_router(lambda: service))
|
||||||
|
|
||||||
|
with patch.object(BindingFileService, "list_files", new_callable=AsyncMock) as list_files:
|
||||||
|
response = TestClient(app).post(
|
||||||
|
"/execution-bindings/files/list",
|
||||||
|
json={"backend_binding_ref": "binding-ref", "path": ".", "unexpected": "top-secret"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {
|
||||||
|
"detail": {
|
||||||
|
"code": "invalid_binding_path",
|
||||||
|
"message": "Binding file path or payload is invalid",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert "top-secret" not in response.text
|
||||||
|
list_files.assert_not_awaited()
|
||||||
|
assert backend.acquired == []
|
||||||
|
assert backend.releases == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_route_validation_returns_422_without_calling_service() -> None:
|
||||||
|
service, backend = _service(_Commands(outputs=[]))
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(create_binding_files_router(lambda: service))
|
||||||
|
|
||||||
|
with patch.object(BindingFileService, "download_file", new_callable=AsyncMock) as download_file:
|
||||||
|
download_response = TestClient(app).post(
|
||||||
|
"/execution-bindings/files/download",
|
||||||
|
json={"backend_binding_ref": "", "path": "", "execution_context": {}},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert download_response.status_code == 422
|
||||||
|
assert isinstance(download_response.json()["detail"], list)
|
||||||
|
download_file.assert_not_awaited()
|
||||||
|
assert backend.acquired == []
|
||||||
|
assert backend.releases == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_read_route_accepts_preview_size_limit() -> None:
|
||||||
|
commands = _Commands(
|
||||||
|
outputs=[
|
||||||
|
_framed(
|
||||||
|
{
|
||||||
|
"path": "report.txt",
|
||||||
|
"size": 262144,
|
||||||
|
"truncated": False,
|
||||||
|
"binary": False,
|
||||||
|
"text": "preview",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
service, backend = _service(commands)
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(create_binding_files_router(lambda: service))
|
||||||
|
|
||||||
|
response = TestClient(app).post(
|
||||||
|
"/execution-bindings/files/read",
|
||||||
|
json={"backend_binding_ref": "binding-ref", "path": "report.txt", "max_bytes": 262144},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["text"] == "preview"
|
||||||
|
assert "262144" in commands.calls[0][0]
|
||||||
|
assert backend.acquired == ["binding-ref"]
|
||||||
|
assert backend.releases == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_binding_file_validation_override_preserves_openapi_request_schemas() -> None:
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(create_binding_files_router(lambda: None))
|
||||||
|
openapi = app.openapi()
|
||||||
|
paths = openapi["paths"]
|
||||||
|
|
||||||
|
assert paths["/execution-bindings/files/list"]["post"]["requestBody"]["content"]["application/json"]["schema"] == {
|
||||||
|
"$ref": "#/components/schemas/BindingFileListRequest"
|
||||||
|
}
|
||||||
|
assert paths["/execution-bindings/files/read"]["post"]["requestBody"]["content"]["application/json"]["schema"] == {
|
||||||
|
"$ref": "#/components/schemas/BindingFileReadRequest"
|
||||||
|
}
|
||||||
|
max_bytes_schema = openapi["components"]["schemas"]["BindingFileReadRequest"]["properties"]["max_bytes"]
|
||||||
|
assert max_bytes_schema["default"] == 262144
|
||||||
|
assert max_bytes_schema["minimum"] == 1
|
||||||
|
assert max_bytes_schema["maximum"] == 262144
|
||||||
@@ -313,12 +313,6 @@ def test_build_runtime_backend_profile_passes_e2b_active_timeout() -> None:
|
|||||||
assert profile.execution_bindings.template == "difys-default-team/dify-agent-local-sandbox"
|
assert profile.execution_bindings.template == "difys-default-team/dify-agent-local-sandbox"
|
||||||
|
|
||||||
|
|
||||||
def test_sandbox_file_upload_limit_defaults_to_tool_file_limit() -> None:
|
|
||||||
settings = ServerSettings()
|
|
||||||
|
|
||||||
assert settings.sandbox_file_upload_max_bytes == 50 * 1024 * 1024
|
|
||||||
|
|
||||||
|
|
||||||
def test_build_runtime_backend_profile_rejects_missing_enterprise_endpoint(
|
def test_build_runtime_backend_profile_rejects_missing_enterprise_endpoint(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
|
||||||
from dify_agent.protocol import (
|
|
||||||
WorkspaceListRequest,
|
|
||||||
WorkspaceReadRequest,
|
|
||||||
WorkspaceUploadRequest,
|
|
||||||
WorkspaceUploadedFile,
|
|
||||||
)
|
|
||||||
from dify_agent.runtime_backend import (
|
|
||||||
RuntimeLayout,
|
|
||||||
RuntimeLease,
|
|
||||||
WorkspaceFileContent,
|
|
||||||
WorkspaceFileEntry,
|
|
||||||
WorkspaceListResult,
|
|
||||||
WorkspaceReadResult,
|
|
||||||
)
|
|
||||||
from dify_agent.server.workspace_files import WorkspaceFileService
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class _Files:
|
|
||||||
calls: list[tuple[str, str]] = field(default_factory=list)
|
|
||||||
|
|
||||||
async def list_directory(self, *, path: str, limit: int) -> WorkspaceListResult:
|
|
||||||
self.calls.append(("list", path))
|
|
||||||
assert limit == 1000
|
|
||||||
return WorkspaceListResult(
|
|
||||||
path=path,
|
|
||||||
entries=(WorkspaceFileEntry(name="note.txt", type="file", size=4, mtime=1),),
|
|
||||||
truncated=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def read_file(self, *, path: str, max_bytes: int) -> WorkspaceReadResult:
|
|
||||||
self.calls.append(("read", path))
|
|
||||||
return WorkspaceReadResult(path=path, size=4, truncated=False, binary=False, text="note")
|
|
||||||
|
|
||||||
async def read_bytes(self, *, path: str, max_bytes: int) -> WorkspaceFileContent:
|
|
||||||
self.calls.append(("bytes", path))
|
|
||||||
return WorkspaceFileContent(path=path, size=4, content=b"note")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class _Lease:
|
|
||||||
files: _Files = field(default_factory=_Files)
|
|
||||||
layout: RuntimeLayout = RuntimeLayout(home_dir="/home/agent", workspace_dir="/workspace")
|
|
||||||
commands: object = field(default_factory=object)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class _Backend:
|
|
||||||
lease: RuntimeLease
|
|
||||||
acquired: list[str] = field(default_factory=list)
|
|
||||||
releases: int = 0
|
|
||||||
|
|
||||||
async def acquire(self, binding_ref: str) -> RuntimeLease:
|
|
||||||
self.acquired.append(binding_ref)
|
|
||||||
return self.lease
|
|
||||||
|
|
||||||
async def release(self, lease: RuntimeLease) -> None:
|
|
||||||
assert lease is self.lease
|
|
||||||
self.releases += 1
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
|
||||||
class _Uploader:
|
|
||||||
uploads: list[tuple[str, str, bytes]] = field(default_factory=list)
|
|
||||||
|
|
||||||
async def upload(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
execution_context: DifyExecutionContextLayerConfig,
|
|
||||||
filename: str,
|
|
||||||
mimetype: str,
|
|
||||||
content: bytes,
|
|
||||||
) -> WorkspaceUploadedFile:
|
|
||||||
del execution_context
|
|
||||||
self.uploads.append((filename, mimetype, content))
|
|
||||||
return WorkspaceUploadedFile(reference="tool-file-1", download_url="https://files/note.txt")
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.anyio
|
|
||||||
async def test_workspace_service_passes_paths_directly_and_leases_each_operation() -> None:
|
|
||||||
lease = _Lease()
|
|
||||||
backend = _Backend(lease=cast(RuntimeLease, lease))
|
|
||||||
uploader = _Uploader()
|
|
||||||
service = WorkspaceFileService(
|
|
||||||
execution_bindings=backend, # pyright: ignore[reportArgumentType]
|
|
||||||
upload_max_bytes=1024,
|
|
||||||
file_uploader=uploader,
|
|
||||||
)
|
|
||||||
|
|
||||||
listing = await service.list_files(WorkspaceListRequest(backend_binding_ref="binding-ref", path="/var/data"))
|
|
||||||
preview = await service.read_file(WorkspaceReadRequest(backend_binding_ref="binding-ref", path="~/note.txt"))
|
|
||||||
uploaded = await service.upload_file(
|
|
||||||
WorkspaceUploadRequest(
|
|
||||||
backend_binding_ref="binding-ref",
|
|
||||||
path="../outside.txt",
|
|
||||||
execution_context=DifyExecutionContextLayerConfig(
|
|
||||||
tenant_id="tenant-1",
|
|
||||||
user_from="account",
|
|
||||||
agent_mode="agent_app",
|
|
||||||
invoke_from="debugger",
|
|
||||||
),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
assert listing.path == "/var/data"
|
|
||||||
assert preview.path == "~/note.txt"
|
|
||||||
assert uploaded.path == "../outside.txt"
|
|
||||||
assert lease.files.calls == [
|
|
||||||
("list", "/var/data"),
|
|
||||||
("read", "~/note.txt"),
|
|
||||||
("bytes", "../outside.txt"),
|
|
||||||
]
|
|
||||||
assert backend.acquired == ["binding-ref", "binding-ref", "binding-ref"]
|
|
||||||
assert backend.releases == 3
|
|
||||||
assert uploader.uploads == [("outside.txt", "text/plain", b"note")]
|
|
||||||
@@ -224,7 +224,6 @@ PLUGIN_DAEMON_PORT=5002
|
|||||||
PLUGIN_DAEMON_KEY=lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi
|
PLUGIN_DAEMON_KEY=lYkiYYT6owG+71oLerGzA7GXCgOT++6ovaezWAjpCjf+Sjc3ZtU+qUEi
|
||||||
PLUGIN_DAEMON_URL=http://plugin_daemon:5002
|
PLUGIN_DAEMON_URL=http://plugin_daemon:5002
|
||||||
PLUGIN_MAX_PACKAGE_SIZE=52428800
|
PLUGIN_MAX_PACKAGE_SIZE=52428800
|
||||||
# Compose maps this byte value to DIFY_AGENT_SANDBOX_FILE_UPLOAD_MAX_BYTES.
|
|
||||||
PLUGIN_MAX_FILE_SIZE=52428800
|
PLUGIN_MAX_FILE_SIZE=52428800
|
||||||
PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600
|
PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600
|
||||||
PLUGIN_PPROF_ENABLED=false
|
PLUGIN_PPROF_ENABLED=false
|
||||||
|
|||||||
@@ -680,7 +680,6 @@ services:
|
|||||||
DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS: ${DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS:-3600}
|
DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS: ${DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS:-3600}
|
||||||
DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN: ${DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN:-}
|
DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN: ${DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN:-}
|
||||||
DIFY_AGENT_E2B_SHELLCTL_PORT: ${DIFY_AGENT_E2B_SHELLCTL_PORT:-5004}
|
DIFY_AGENT_E2B_SHELLCTL_PORT: ${DIFY_AGENT_E2B_SHELLCTL_PORT:-5004}
|
||||||
DIFY_AGENT_SANDBOX_FILE_UPLOAD_MAX_BYTES: ${PLUGIN_MAX_FILE_SIZE:-52428800}
|
|
||||||
DIFY_AGENT_STUB_API_BASE_URL: ${DIFY_AGENT_STUB_API_BASE_URL:-http://agent_backend:5050/agent-stub}
|
DIFY_AGENT_STUB_API_BASE_URL: ${DIFY_AGENT_STUB_API_BASE_URL:-http://agent_backend:5050/agent-stub}
|
||||||
DIFY_AGENT_SANDBOX_FILES_BASE_URL: ${DIFY_AGENT_SANDBOX_FILES_BASE_URL:-http://api:5001}
|
DIFY_AGENT_SANDBOX_FILES_BASE_URL: ${DIFY_AGENT_SANDBOX_FILES_BASE_URL:-http://api:5001}
|
||||||
# This is security-sensitive: it derives the JWE encryption key for Agent Stub bearer tokens.
|
# This is security-sensitive: it derives the JWE encryption key for Agent Stub bearer tokens.
|
||||||
|
|||||||
@@ -686,7 +686,6 @@ services:
|
|||||||
DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS: ${DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS:-3600}
|
DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS: ${DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS:-3600}
|
||||||
DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN: ${DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN:-}
|
DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN: ${DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN:-}
|
||||||
DIFY_AGENT_E2B_SHELLCTL_PORT: ${DIFY_AGENT_E2B_SHELLCTL_PORT:-5004}
|
DIFY_AGENT_E2B_SHELLCTL_PORT: ${DIFY_AGENT_E2B_SHELLCTL_PORT:-5004}
|
||||||
DIFY_AGENT_SANDBOX_FILE_UPLOAD_MAX_BYTES: ${PLUGIN_MAX_FILE_SIZE:-52428800}
|
|
||||||
DIFY_AGENT_STUB_API_BASE_URL: ${DIFY_AGENT_STUB_API_BASE_URL:-http://agent_backend:5050/agent-stub}
|
DIFY_AGENT_STUB_API_BASE_URL: ${DIFY_AGENT_STUB_API_BASE_URL:-http://agent_backend:5050/agent-stub}
|
||||||
DIFY_AGENT_SANDBOX_FILES_BASE_URL: ${DIFY_AGENT_SANDBOX_FILES_BASE_URL:-http://api:5001}
|
DIFY_AGENT_SANDBOX_FILES_BASE_URL: ${DIFY_AGENT_SANDBOX_FILES_BASE_URL:-http://api:5001}
|
||||||
# This is security-sensitive: it derives the JWE encryption key for Agent Stub bearer tokens.
|
# This is security-sensitive: it derives the JWE encryption key for Agent Stub bearer tokens.
|
||||||
|
|||||||
@@ -33,9 +33,6 @@ DIFY_AGENT_E2B_TEMPLATE=difys-default-team/dify-agent-local-sandbox
|
|||||||
DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS=3600
|
DIFY_AGENT_E2B_ACTIVE_TIMEOUT_SECONDS=3600
|
||||||
DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN=
|
DIFY_AGENT_E2B_SHELLCTL_AUTH_TOKEN=
|
||||||
DIFY_AGENT_E2B_SHELLCTL_PORT=5004
|
DIFY_AGENT_E2B_SHELLCTL_PORT=5004
|
||||||
# Standalone/direct-container byte limit. Full Docker Compose derives this from
|
|
||||||
# PLUGIN_MAX_FILE_SIZE in docker/.env.
|
|
||||||
DIFY_AGENT_SANDBOX_FILE_UPLOAD_MAX_BYTES=52428800
|
|
||||||
# Sandbox-reachable Dify API base for signed /files/* transfers.
|
# Sandbox-reachable Dify API base for signed /files/* transfers.
|
||||||
DIFY_AGENT_SANDBOX_FILES_BASE_URL=http://api:5001
|
DIFY_AGENT_SANDBOX_FILES_BASE_URL=http://api:5001
|
||||||
DIFY_AGENT_STUB_API_BASE_URL=http://agent_backend:5050/agent-stub
|
DIFY_AGENT_STUB_API_BASE_URL=http://agent_backend:5050/agent-stub
|
||||||
|
|||||||
@@ -157,9 +157,9 @@ import {
|
|||||||
zPostAgentByAgentIdPublishBody,
|
zPostAgentByAgentIdPublishBody,
|
||||||
zPostAgentByAgentIdPublishPath,
|
zPostAgentByAgentIdPublishPath,
|
||||||
zPostAgentByAgentIdPublishResponse,
|
zPostAgentByAgentIdPublishResponse,
|
||||||
zPostAgentByAgentIdSandboxFilesUploadBody,
|
zPostAgentByAgentIdSandboxFilesDownloadBody,
|
||||||
zPostAgentByAgentIdSandboxFilesUploadPath,
|
zPostAgentByAgentIdSandboxFilesDownloadPath,
|
||||||
zPostAgentByAgentIdSandboxFilesUploadResponse,
|
zPostAgentByAgentIdSandboxFilesDownloadResponse,
|
||||||
zPostAgentByAgentIdSkillsBySlugInferToolsPath,
|
zPostAgentByAgentIdSkillsBySlugInferToolsPath,
|
||||||
zPostAgentByAgentIdSkillsBySlugInferToolsResponse,
|
zPostAgentByAgentIdSkillsBySlugInferToolsResponse,
|
||||||
zPostAgentByAgentIdSkillsUploadBody,
|
zPostAgentByAgentIdSkillsUploadBody,
|
||||||
@@ -1179,6 +1179,30 @@ export const referencingWorkflows = {
|
|||||||
get: get28,
|
get: get28,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a ToolFile from one Agent App Binding file and return its download URL
|
||||||
|
*/
|
||||||
|
export const post17 = oc
|
||||||
|
.route({
|
||||||
|
description: 'Create a ToolFile from one Agent App Binding file and return its download URL',
|
||||||
|
inputStructure: 'detailed',
|
||||||
|
method: 'POST',
|
||||||
|
operationId: 'postAgentByAgentIdSandboxFilesDownload',
|
||||||
|
path: '/agent/{agent_id}/sandbox/files/download',
|
||||||
|
tags: ['console'],
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
body: zPostAgentByAgentIdSandboxFilesDownloadBody,
|
||||||
|
params: zPostAgentByAgentIdSandboxFilesDownloadPath,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(zPostAgentByAgentIdSandboxFilesDownloadResponse)
|
||||||
|
|
||||||
|
export const download5 = {
|
||||||
|
post: post17,
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read a text/binary preview file in an Agent App conversation sandbox
|
* Read a text/binary preview file in an Agent App conversation sandbox
|
||||||
*/
|
*/
|
||||||
@@ -1203,30 +1227,6 @@ export const read = {
|
|||||||
get: get29,
|
get: get29,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload one Agent App sandbox file and return a signed download URL
|
|
||||||
*/
|
|
||||||
export const post17 = oc
|
|
||||||
.route({
|
|
||||||
description: 'Upload one Agent App sandbox file and return a signed download URL',
|
|
||||||
inputStructure: 'detailed',
|
|
||||||
method: 'POST',
|
|
||||||
operationId: 'postAgentByAgentIdSandboxFilesUpload',
|
|
||||||
path: '/agent/{agent_id}/sandbox/files/upload',
|
|
||||||
tags: ['console'],
|
|
||||||
})
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
body: zPostAgentByAgentIdSandboxFilesUploadBody,
|
|
||||||
params: zPostAgentByAgentIdSandboxFilesUploadPath,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.output(zPostAgentByAgentIdSandboxFilesUploadResponse)
|
|
||||||
|
|
||||||
export const upload2 = {
|
|
||||||
post: post17,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List a directory in an Agent App conversation sandbox
|
* List a directory in an Agent App conversation sandbox
|
||||||
*/
|
*/
|
||||||
@@ -1249,8 +1249,8 @@ export const get30 = oc
|
|||||||
|
|
||||||
export const files5 = {
|
export const files5 = {
|
||||||
get: get30,
|
get: get30,
|
||||||
|
download: download5,
|
||||||
read,
|
read,
|
||||||
upload: upload2,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1294,7 +1294,7 @@ export const post18 = oc
|
|||||||
)
|
)
|
||||||
.output(zPostAgentByAgentIdSkillsUploadResponse)
|
.output(zPostAgentByAgentIdSkillsUploadResponse)
|
||||||
|
|
||||||
export const upload3 = {
|
export const upload2 = {
|
||||||
post: post18,
|
post: post18,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1338,7 +1338,7 @@ export const bySlug = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const skills3 = {
|
export const skills3 = {
|
||||||
upload: upload3,
|
upload: upload2,
|
||||||
bySlug,
|
bySlug,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -433,6 +433,16 @@ export type SandboxListResponse = {
|
|||||||
truncated?: boolean
|
truncated?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AgentSandboxDownloadPayload = {
|
||||||
|
caller_id: string
|
||||||
|
caller_type: 'build_draft' | 'conversation'
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SandboxDownloadResponse = {
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
export type SandboxReadResponse = {
|
export type SandboxReadResponse = {
|
||||||
binary: boolean
|
binary: boolean
|
||||||
path: string
|
path: string
|
||||||
@@ -441,16 +451,6 @@ export type SandboxReadResponse = {
|
|||||||
truncated: boolean
|
truncated: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AgentSandboxUploadPayload = {
|
|
||||||
caller_id: string
|
|
||||||
caller_type: 'build_draft' | 'conversation'
|
|
||||||
path: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SandboxUploadResponse = {
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type AgentSkillUploadResponse = {
|
export type AgentSkillUploadResponse = {
|
||||||
manifest: SkillManifest
|
manifest: SkillManifest
|
||||||
skill: AgentUploadedSkillResponse
|
skill: AgentUploadedSkillResponse
|
||||||
@@ -3117,6 +3117,22 @@ export type GetAgentByAgentIdSandboxFilesResponses = {
|
|||||||
export type GetAgentByAgentIdSandboxFilesResponse =
|
export type GetAgentByAgentIdSandboxFilesResponse =
|
||||||
GetAgentByAgentIdSandboxFilesResponses[keyof GetAgentByAgentIdSandboxFilesResponses]
|
GetAgentByAgentIdSandboxFilesResponses[keyof GetAgentByAgentIdSandboxFilesResponses]
|
||||||
|
|
||||||
|
export type PostAgentByAgentIdSandboxFilesDownloadData = {
|
||||||
|
body: AgentSandboxDownloadPayload
|
||||||
|
path: {
|
||||||
|
agent_id: string
|
||||||
|
}
|
||||||
|
query?: never
|
||||||
|
url: '/agent/{agent_id}/sandbox/files/download'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PostAgentByAgentIdSandboxFilesDownloadResponses = {
|
||||||
|
200: SandboxDownloadResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PostAgentByAgentIdSandboxFilesDownloadResponse =
|
||||||
|
PostAgentByAgentIdSandboxFilesDownloadResponses[keyof PostAgentByAgentIdSandboxFilesDownloadResponses]
|
||||||
|
|
||||||
export type GetAgentByAgentIdSandboxFilesReadData = {
|
export type GetAgentByAgentIdSandboxFilesReadData = {
|
||||||
body?: never
|
body?: never
|
||||||
path: {
|
path: {
|
||||||
@@ -3137,22 +3153,6 @@ export type GetAgentByAgentIdSandboxFilesReadResponses = {
|
|||||||
export type GetAgentByAgentIdSandboxFilesReadResponse =
|
export type GetAgentByAgentIdSandboxFilesReadResponse =
|
||||||
GetAgentByAgentIdSandboxFilesReadResponses[keyof GetAgentByAgentIdSandboxFilesReadResponses]
|
GetAgentByAgentIdSandboxFilesReadResponses[keyof GetAgentByAgentIdSandboxFilesReadResponses]
|
||||||
|
|
||||||
export type PostAgentByAgentIdSandboxFilesUploadData = {
|
|
||||||
body: AgentSandboxUploadPayload
|
|
||||||
path: {
|
|
||||||
agent_id: string
|
|
||||||
}
|
|
||||||
query?: never
|
|
||||||
url: '/agent/{agent_id}/sandbox/files/upload'
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PostAgentByAgentIdSandboxFilesUploadResponses = {
|
|
||||||
200: SandboxUploadResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PostAgentByAgentIdSandboxFilesUploadResponse =
|
|
||||||
PostAgentByAgentIdSandboxFilesUploadResponses[keyof PostAgentByAgentIdSandboxFilesUploadResponses]
|
|
||||||
|
|
||||||
export type PostAgentByAgentIdSkillsUploadData = {
|
export type PostAgentByAgentIdSkillsUploadData = {
|
||||||
body: {
|
body: {
|
||||||
file: Blob | File
|
file: Blob | File
|
||||||
|
|||||||
@@ -200,6 +200,22 @@ export const zSandboxInfoResponse = z.object({
|
|||||||
workspace_cwd: z.string(),
|
workspace_cwd: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AgentSandboxDownloadPayload
|
||||||
|
*/
|
||||||
|
export const zAgentSandboxDownloadPayload = z.object({
|
||||||
|
caller_id: z.string().min(1),
|
||||||
|
caller_type: z.enum(['build_draft', 'conversation']),
|
||||||
|
path: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SandboxDownloadResponse
|
||||||
|
*/
|
||||||
|
export const zSandboxDownloadResponse = z.object({
|
||||||
|
url: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SandboxReadResponse
|
* SandboxReadResponse
|
||||||
*/
|
*/
|
||||||
@@ -211,22 +227,6 @@ export const zSandboxReadResponse = z.object({
|
|||||||
truncated: z.boolean(),
|
truncated: z.boolean(),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* AgentSandboxUploadPayload
|
|
||||||
*/
|
|
||||||
export const zAgentSandboxUploadPayload = z.object({
|
|
||||||
caller_id: z.string().min(1),
|
|
||||||
caller_type: z.enum(['build_draft', 'conversation']),
|
|
||||||
path: z.string().min(1),
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SandboxUploadResponse
|
|
||||||
*/
|
|
||||||
export const zSandboxUploadResponse = z.object({
|
|
||||||
url: z.string(),
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AgentConfigSnapshotRestoreResponse
|
* AgentConfigSnapshotRestoreResponse
|
||||||
*/
|
*/
|
||||||
@@ -3500,6 +3500,17 @@ export const zGetAgentByAgentIdSandboxFilesQuery = z.object({
|
|||||||
*/
|
*/
|
||||||
export const zGetAgentByAgentIdSandboxFilesResponse = zSandboxListResponse
|
export const zGetAgentByAgentIdSandboxFilesResponse = zSandboxListResponse
|
||||||
|
|
||||||
|
export const zPostAgentByAgentIdSandboxFilesDownloadBody = zAgentSandboxDownloadPayload
|
||||||
|
|
||||||
|
export const zPostAgentByAgentIdSandboxFilesDownloadPath = z.object({
|
||||||
|
agent_id: z.uuid(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download URL returned
|
||||||
|
*/
|
||||||
|
export const zPostAgentByAgentIdSandboxFilesDownloadResponse = zSandboxDownloadResponse
|
||||||
|
|
||||||
export const zGetAgentByAgentIdSandboxFilesReadPath = z.object({
|
export const zGetAgentByAgentIdSandboxFilesReadPath = z.object({
|
||||||
agent_id: z.uuid(),
|
agent_id: z.uuid(),
|
||||||
})
|
})
|
||||||
@@ -3515,17 +3526,6 @@ export const zGetAgentByAgentIdSandboxFilesReadQuery = z.object({
|
|||||||
*/
|
*/
|
||||||
export const zGetAgentByAgentIdSandboxFilesReadResponse = zSandboxReadResponse
|
export const zGetAgentByAgentIdSandboxFilesReadResponse = zSandboxReadResponse
|
||||||
|
|
||||||
export const zPostAgentByAgentIdSandboxFilesUploadBody = zAgentSandboxUploadPayload
|
|
||||||
|
|
||||||
export const zPostAgentByAgentIdSandboxFilesUploadPath = z.object({
|
|
||||||
agent_id: z.uuid(),
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uploaded
|
|
||||||
*/
|
|
||||||
export const zPostAgentByAgentIdSandboxFilesUploadResponse = zSandboxUploadResponse
|
|
||||||
|
|
||||||
export const zPostAgentByAgentIdSkillsUploadBody = z.object({
|
export const zPostAgentByAgentIdSkillsUploadBody = z.object({
|
||||||
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
|
file: z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -406,9 +406,9 @@ import {
|
|||||||
zPostAppsByAppIdWorkflowCommentsByCommentIdResolveResponse,
|
zPostAppsByAppIdWorkflowCommentsByCommentIdResolveResponse,
|
||||||
zPostAppsByAppIdWorkflowCommentsPath,
|
zPostAppsByAppIdWorkflowCommentsPath,
|
||||||
zPostAppsByAppIdWorkflowCommentsResponse,
|
zPostAppsByAppIdWorkflowCommentsResponse,
|
||||||
zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadBody,
|
zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesDownloadBody,
|
||||||
zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadPath,
|
zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesDownloadPath,
|
||||||
zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponse,
|
zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesDownloadResponse,
|
||||||
zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopPath,
|
zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopPath,
|
||||||
zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponse,
|
zPostAppsByAppIdWorkflowRunsTasksByTaskIdStopResponse,
|
||||||
zPostAppsByAppIdWorkflowsByWorkflowIdRestorePath,
|
zPostAppsByAppIdWorkflowsByWorkflowIdRestorePath,
|
||||||
@@ -3050,6 +3050,31 @@ export const byRunId = {
|
|||||||
nodeExecutions,
|
nodeExecutions,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a ToolFile from one workflow Agent Binding file and return its download URL
|
||||||
|
*/
|
||||||
|
export const post42 = oc
|
||||||
|
.route({
|
||||||
|
description:
|
||||||
|
'Create a ToolFile from one workflow Agent Binding file and return its download URL',
|
||||||
|
inputStructure: 'detailed',
|
||||||
|
method: 'POST',
|
||||||
|
operationId: 'postAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesDownload',
|
||||||
|
path: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/download',
|
||||||
|
tags: ['console'],
|
||||||
|
})
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
body: zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesDownloadBody,
|
||||||
|
params: zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesDownloadPath,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.output(zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesDownloadResponse)
|
||||||
|
|
||||||
|
export const download5 = {
|
||||||
|
post: post42,
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read a text/binary preview file in a workflow Agent node sandbox
|
* Read a text/binary preview file in a workflow Agent node sandbox
|
||||||
*/
|
*/
|
||||||
@@ -3074,30 +3099,6 @@ export const read = {
|
|||||||
get: get58,
|
get: get58,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload one workflow Agent sandbox file and return a signed download URL
|
|
||||||
*/
|
|
||||||
export const post42 = oc
|
|
||||||
.route({
|
|
||||||
description: 'Upload one workflow Agent sandbox file and return a signed download URL',
|
|
||||||
inputStructure: 'detailed',
|
|
||||||
method: 'POST',
|
|
||||||
operationId: 'postAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUpload',
|
|
||||||
path: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/upload',
|
|
||||||
tags: ['console'],
|
|
||||||
})
|
|
||||||
.input(
|
|
||||||
z.object({
|
|
||||||
body: zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadBody,
|
|
||||||
params: zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadPath,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.output(zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponse)
|
|
||||||
|
|
||||||
export const upload3 = {
|
|
||||||
post: post42,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List a directory in a workflow Agent node sandbox
|
* List a directory in a workflow Agent node sandbox
|
||||||
*/
|
*/
|
||||||
@@ -3120,8 +3121,8 @@ export const get59 = oc
|
|||||||
|
|
||||||
export const files5 = {
|
export const files5 = {
|
||||||
get: get59,
|
get: get59,
|
||||||
|
download: download5,
|
||||||
read,
|
read,
|
||||||
upload: upload3,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sandbox = {
|
export const sandbox = {
|
||||||
|
|||||||
@@ -865,6 +865,15 @@ export type SandboxListResponse = {
|
|||||||
truncated?: boolean
|
truncated?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type WorkflowAgentSandboxDownloadPayload = {
|
||||||
|
node_execution_id: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SandboxDownloadResponse = {
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
export type SandboxReadResponse = {
|
export type SandboxReadResponse = {
|
||||||
binary: boolean
|
binary: boolean
|
||||||
path: string
|
path: string
|
||||||
@@ -873,15 +882,6 @@ export type SandboxReadResponse = {
|
|||||||
truncated: boolean
|
truncated: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WorkflowAgentSandboxUploadPayload = {
|
|
||||||
node_execution_id: string
|
|
||||||
path: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SandboxUploadResponse = {
|
|
||||||
url: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WorkflowCommentBasicList = {
|
export type WorkflowCommentBasicList = {
|
||||||
data: Array<WorkflowCommentBasic>
|
data: Array<WorkflowCommentBasic>
|
||||||
}
|
}
|
||||||
@@ -5700,6 +5700,25 @@ export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFi
|
|||||||
export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse =
|
export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse =
|
||||||
GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponses[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponses]
|
GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponses[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponses]
|
||||||
|
|
||||||
|
export type PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesDownloadData = {
|
||||||
|
body: WorkflowAgentSandboxDownloadPayload
|
||||||
|
path: {
|
||||||
|
app_id: string
|
||||||
|
node_id: string
|
||||||
|
workflow_run_id: string
|
||||||
|
}
|
||||||
|
query?: never
|
||||||
|
url: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/download'
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesDownloadResponses =
|
||||||
|
{
|
||||||
|
200: SandboxDownloadResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesDownloadResponse =
|
||||||
|
PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesDownloadResponses[keyof PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesDownloadResponses]
|
||||||
|
|
||||||
export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadData = {
|
export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadData = {
|
||||||
body?: never
|
body?: never
|
||||||
path: {
|
path: {
|
||||||
@@ -5721,25 +5740,6 @@ export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFi
|
|||||||
export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse =
|
export type GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse =
|
||||||
GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponses[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponses]
|
GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponses[keyof GetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponses]
|
||||||
|
|
||||||
export type PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadData = {
|
|
||||||
body: WorkflowAgentSandboxUploadPayload
|
|
||||||
path: {
|
|
||||||
app_id: string
|
|
||||||
node_id: string
|
|
||||||
workflow_run_id: string
|
|
||||||
}
|
|
||||||
query?: never
|
|
||||||
url: '/apps/{app_id}/workflow-runs/{workflow_run_id}/agent-nodes/{node_id}/sandbox/files/upload'
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponses =
|
|
||||||
{
|
|
||||||
200: SandboxUploadResponse
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponse =
|
|
||||||
PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponses[keyof PostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponses]
|
|
||||||
|
|
||||||
export type GetAppsByAppIdWorkflowCommentsData = {
|
export type GetAppsByAppIdWorkflowCommentsData = {
|
||||||
body?: never
|
body?: never
|
||||||
path: {
|
path: {
|
||||||
|
|||||||
@@ -545,6 +545,21 @@ export const zWorkflowRunExportResponse = z.object({
|
|||||||
status: z.string(),
|
status: z.string(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WorkflowAgentSandboxDownloadPayload
|
||||||
|
*/
|
||||||
|
export const zWorkflowAgentSandboxDownloadPayload = z.object({
|
||||||
|
node_execution_id: z.string().min(1),
|
||||||
|
path: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SandboxDownloadResponse
|
||||||
|
*/
|
||||||
|
export const zSandboxDownloadResponse = z.object({
|
||||||
|
url: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SandboxReadResponse
|
* SandboxReadResponse
|
||||||
*/
|
*/
|
||||||
@@ -556,21 +571,6 @@ export const zSandboxReadResponse = z.object({
|
|||||||
truncated: z.boolean(),
|
truncated: z.boolean(),
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* WorkflowAgentSandboxUploadPayload
|
|
||||||
*/
|
|
||||||
export const zWorkflowAgentSandboxUploadPayload = z.object({
|
|
||||||
node_execution_id: z.string().min(1),
|
|
||||||
path: z.string().min(1),
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SandboxUploadResponse
|
|
||||||
*/
|
|
||||||
export const zSandboxUploadResponse = z.object({
|
|
||||||
url: z.string(),
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* WorkflowCommentCreatePayload
|
* WorkflowCommentCreatePayload
|
||||||
*/
|
*/
|
||||||
@@ -5960,6 +5960,22 @@ export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandbox
|
|||||||
export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse =
|
export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesResponse =
|
||||||
zSandboxListResponse
|
zSandboxListResponse
|
||||||
|
|
||||||
|
export const zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesDownloadBody =
|
||||||
|
zWorkflowAgentSandboxDownloadPayload
|
||||||
|
|
||||||
|
export const zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesDownloadPath =
|
||||||
|
z.object({
|
||||||
|
app_id: z.uuid(),
|
||||||
|
node_id: z.string(),
|
||||||
|
workflow_run_id: z.uuid(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download URL returned
|
||||||
|
*/
|
||||||
|
export const zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesDownloadResponse =
|
||||||
|
zSandboxDownloadResponse
|
||||||
|
|
||||||
export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadPath =
|
export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadPath =
|
||||||
z.object({
|
z.object({
|
||||||
app_id: z.uuid(),
|
app_id: z.uuid(),
|
||||||
@@ -5979,22 +5995,6 @@ export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandbox
|
|||||||
export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse =
|
export const zGetAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesReadResponse =
|
||||||
zSandboxReadResponse
|
zSandboxReadResponse
|
||||||
|
|
||||||
export const zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadBody =
|
|
||||||
zWorkflowAgentSandboxUploadPayload
|
|
||||||
|
|
||||||
export const zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadPath =
|
|
||||||
z.object({
|
|
||||||
app_id: z.uuid(),
|
|
||||||
node_id: z.string(),
|
|
||||||
workflow_run_id: z.uuid(),
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Uploaded
|
|
||||||
*/
|
|
||||||
export const zPostAppsByAppIdWorkflowRunsByWorkflowRunIdAgentNodesByNodeIdSandboxFilesUploadResponse =
|
|
||||||
zSandboxUploadResponse
|
|
||||||
|
|
||||||
export const zGetAppsByAppIdWorkflowCommentsPath = z.object({
|
export const zGetAppsByAppIdWorkflowCommentsPath = z.object({
|
||||||
app_id: z.uuid(),
|
app_id: z.uuid(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ describe('generated sandbox contracts', () => {
|
|||||||
])('exposes the %s file operations', (_, sandbox) => {
|
])('exposes the %s file operations', (_, sandbox) => {
|
||||||
expect(sandbox.files.get).toBeDefined()
|
expect(sandbox.files.get).toBeDefined()
|
||||||
expect(sandbox.files.read.get).toBeDefined()
|
expect(sandbox.files.read.get).toBeDefined()
|
||||||
expect(sandbox.files.upload.post).toBeDefined()
|
expect(sandbox.files.download.post).toBeDefined()
|
||||||
|
expect('upload' in sandbox.files).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+14
@@ -14,6 +14,8 @@ const mocks = vi.hoisted(() => ({
|
|||||||
checkoutBuildDraft: vi.fn(),
|
checkoutBuildDraft: vi.fn(),
|
||||||
completeBuildConversation: undefined as (() => void) | undefined,
|
completeBuildConversation: undefined as (() => void) | undefined,
|
||||||
deleteBuildDraft: vi.fn(),
|
deleteBuildDraft: vi.fn(),
|
||||||
|
downloadAgentSandboxFile: vi.fn(),
|
||||||
|
downloadWorkflowSandboxFile: vi.fn(),
|
||||||
loadBuildDraft: vi.fn(),
|
loadBuildDraft: vi.fn(),
|
||||||
applyBuildDraft: vi.fn(),
|
applyBuildDraft: vi.fn(),
|
||||||
finalizeBuildChat: vi.fn(),
|
finalizeBuildChat: vi.fn(),
|
||||||
@@ -333,6 +335,11 @@ vi.mock('@/service/client', async () => {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
download: {
|
||||||
|
post: {
|
||||||
|
mutationOptions: () => ({ mutationFn: mocks.downloadAgentSandboxFile }),
|
||||||
|
},
|
||||||
|
},
|
||||||
upload: {
|
upload: {
|
||||||
post: {
|
post: {
|
||||||
mutationOptions: () => ({ mutationFn: mocks.uploadAgentSandboxFile }),
|
mutationOptions: () => ({ mutationFn: mocks.uploadAgentSandboxFile }),
|
||||||
@@ -377,6 +384,13 @@ vi.mock('@/service/client', async () => {
|
|||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
download: {
|
||||||
|
post: {
|
||||||
|
mutationOptions: () => ({
|
||||||
|
mutationFn: mocks.downloadWorkflowSandboxFile,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
upload: {
|
upload: {
|
||||||
post: {
|
post: {
|
||||||
mutationOptions: () => ({ mutationFn: mocks.uploadWorkflowSandboxFile }),
|
mutationOptions: () => ({ mutationFn: mocks.uploadWorkflowSandboxFile }),
|
||||||
|
|||||||
+349
-80
@@ -1,3 +1,4 @@
|
|||||||
|
import type { AgentWorkingDirectorySource } from '../working-directory-panel'
|
||||||
import { toast } from '@langgenius/dify-ui/toast'
|
import { toast } from '@langgenius/dify-ui/toast'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
@@ -17,32 +18,82 @@ const mocks = vi.hoisted(() => ({
|
|||||||
sandboxInfoQueryOptions: vi.fn(),
|
sandboxInfoQueryOptions: vi.fn(),
|
||||||
sandboxFilesQueryOptions: vi.fn(),
|
sandboxFilesQueryOptions: vi.fn(),
|
||||||
sandboxFileReadQueryOptions: vi.fn(),
|
sandboxFileReadQueryOptions: vi.fn(),
|
||||||
sandboxFileUploadMutationFn: vi.fn(async (_input: unknown) => ({
|
sandboxFileDownloadMutationFn: vi.fn(async (_input: unknown) => ({
|
||||||
url: 'https://example.com/sandbox-file',
|
url: 'https://example.com/sandbox-file',
|
||||||
})),
|
})),
|
||||||
workflowSandboxFilesQueryOptions: vi.fn(),
|
workflowSandboxFilesQueryOptions: vi.fn(),
|
||||||
workflowSandboxFileReadQueryOptions: vi.fn(),
|
workflowSandboxFileReadQueryOptions: vi.fn(),
|
||||||
workflowSandboxFileUploadMutationFn: vi.fn(async (_input: unknown) => ({
|
workflowSandboxFileDownloadMutationFn: vi.fn(async (_input: unknown) => ({
|
||||||
url: 'https://example.com/workflow-sandbox-file',
|
url: 'https://example.com/workflow-sandbox-file',
|
||||||
})),
|
})),
|
||||||
sandboxFileUploadClientPost: vi.fn(async (_input: unknown) => ({
|
sandboxFileDownloadClientPost: vi.fn(async (_input: unknown) => ({
|
||||||
url: 'https://example.com/chart.png',
|
url: 'https://example.com/chart.png',
|
||||||
})),
|
})),
|
||||||
workflowSandboxFileUploadClientPost: vi.fn(async (_input: unknown) => ({
|
workflowSandboxFileDownloadClientPost: vi.fn(async (_input: unknown) => ({
|
||||||
url: 'https://example.com/workflow-chart.png',
|
url: 'https://example.com/workflow-chart.png',
|
||||||
})),
|
})),
|
||||||
downloadUrl: vi.fn(),
|
downloadUrl: vi.fn(),
|
||||||
toastSuccess: vi.fn(),
|
toastSuccess: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
const agentSource = {
|
||||||
|
type: 'agent',
|
||||||
|
agentId: 'agent-1',
|
||||||
|
callerType: 'conversation',
|
||||||
|
callerId: 'conversation-1',
|
||||||
|
} satisfies AgentWorkingDirectorySource
|
||||||
|
|
||||||
|
const workflowSource = {
|
||||||
|
type: 'workflow-node',
|
||||||
|
appId: 'app-1',
|
||||||
|
workflowRunId: 'run-1',
|
||||||
|
nodeId: 'node-1',
|
||||||
|
nodeExecutionId: 'execution-1',
|
||||||
|
} satisfies AgentWorkingDirectorySource
|
||||||
|
|
||||||
|
const previewSourceCases = [
|
||||||
|
{
|
||||||
|
label: 'Agent',
|
||||||
|
source: agentSource,
|
||||||
|
identitySource: {
|
||||||
|
...agentSource,
|
||||||
|
callerId: 'conversation-2',
|
||||||
|
} satisfies AgentWorkingDirectorySource,
|
||||||
|
imagePaths: ['workspace/chart-a.png', 'workspace/chart-b.png'],
|
||||||
|
nonImagePath: 'workspace/model.bin',
|
||||||
|
previewClient: mocks.sandboxFileDownloadClientPost,
|
||||||
|
urls: [
|
||||||
|
'https://example.com/agent-chart-a.png',
|
||||||
|
'https://example.com/agent-chart-caller-b.png',
|
||||||
|
'https://example.com/agent-chart-path-c.png',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Workflow',
|
||||||
|
source: workflowSource,
|
||||||
|
identitySource: {
|
||||||
|
...workflowSource,
|
||||||
|
nodeExecutionId: 'execution-2',
|
||||||
|
} satisfies AgentWorkingDirectorySource,
|
||||||
|
imagePaths: ['chart-a.png', 'chart-b.png'],
|
||||||
|
nonImagePath: 'model.bin',
|
||||||
|
previewClient: mocks.workflowSandboxFileDownloadClientPost,
|
||||||
|
urls: [
|
||||||
|
'https://example.com/workflow-chart-a.png',
|
||||||
|
'https://example.com/workflow-chart-execution-b.png',
|
||||||
|
'https://example.com/workflow-chart-path-c.png',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
vi.mock('@/service/client', () => ({
|
vi.mock('@/service/client', () => ({
|
||||||
consoleClient: {
|
consoleClient: {
|
||||||
agent: {
|
agent: {
|
||||||
byAgentId: {
|
byAgentId: {
|
||||||
sandbox: {
|
sandbox: {
|
||||||
files: {
|
files: {
|
||||||
upload: {
|
download: {
|
||||||
post: mocks.sandboxFileUploadClientPost,
|
post: mocks.sandboxFileDownloadClientPost,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -56,8 +107,8 @@ vi.mock('@/service/client', () => ({
|
|||||||
byNodeId: {
|
byNodeId: {
|
||||||
sandbox: {
|
sandbox: {
|
||||||
files: {
|
files: {
|
||||||
upload: {
|
download: {
|
||||||
post: mocks.workflowSandboxFileUploadClientPost,
|
post: mocks.workflowSandboxFileDownloadClientPost,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -84,9 +135,9 @@ vi.mock('@/service/client', () => ({
|
|||||||
queryOptions: mocks.sandboxFileReadQueryOptions,
|
queryOptions: mocks.sandboxFileReadQueryOptions,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
upload: {
|
download: {
|
||||||
post: {
|
post: {
|
||||||
mutationOptions: () => ({ mutationFn: mocks.sandboxFileUploadMutationFn }),
|
mutationOptions: () => ({ mutationFn: mocks.sandboxFileDownloadMutationFn }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -109,10 +160,10 @@ vi.mock('@/service/client', () => ({
|
|||||||
queryOptions: mocks.workflowSandboxFileReadQueryOptions,
|
queryOptions: mocks.workflowSandboxFileReadQueryOptions,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
upload: {
|
download: {
|
||||||
post: {
|
post: {
|
||||||
mutationOptions: () => ({
|
mutationOptions: () => ({
|
||||||
mutationFn: mocks.workflowSandboxFileUploadMutationFn,
|
mutationFn: mocks.workflowSandboxFileDownloadMutationFn,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -139,57 +190,114 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
|||||||
|
|
||||||
function createDeferred<T>() {
|
function createDeferred<T>() {
|
||||||
let resolve!: (value: T) => void
|
let resolve!: (value: T) => void
|
||||||
const promise = new Promise<T>((promiseResolve) => {
|
let reject!: (reason?: unknown) => void
|
||||||
|
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||||
resolve = promiseResolve
|
resolve = promiseResolve
|
||||||
|
reject = promiseReject
|
||||||
})
|
})
|
||||||
return { promise, resolve }
|
return { promise, reject, resolve }
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderWorkingDirectoryPanel() {
|
type RenderWorkingDirectoryPanelOptions = {
|
||||||
|
open?: boolean
|
||||||
|
source?: AgentWorkingDirectorySource
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWorkingDirectoryPanel({
|
||||||
|
open = true,
|
||||||
|
source = agentSource,
|
||||||
|
}: RenderWorkingDirectoryPanelOptions = {}) {
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: { retry: false },
|
queries: { retry: false },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return render(
|
const rendered = render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<AgentWorkingDirectoryPanel
|
<AgentWorkingDirectoryPanel open={open} onOpenChange={vi.fn()} source={source} />
|
||||||
open
|
|
||||||
onOpenChange={vi.fn()}
|
|
||||||
source={{
|
|
||||||
type: 'agent',
|
|
||||||
agentId: 'agent-1',
|
|
||||||
callerType: 'conversation',
|
|
||||||
callerId: 'conversation-1',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
)
|
)
|
||||||
|
return {
|
||||||
|
...rendered,
|
||||||
|
rerenderPanel: (nextOptions: Required<RenderWorkingDirectoryPanelOptions>) => {
|
||||||
|
rendered.rerender(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AgentWorkingDirectoryPanel
|
||||||
|
open={nextOptions.open}
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
source={nextOptions.source}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderWorkflowWorkingDirectoryPanel() {
|
function renderWorkflowWorkingDirectoryPanel() {
|
||||||
const queryClient = new QueryClient({
|
return renderWorkingDirectoryPanel({ source: workflowSource })
|
||||||
defaultOptions: {
|
}
|
||||||
queries: { retry: false },
|
|
||||||
|
function mockFileListEntries(
|
||||||
|
source: AgentWorkingDirectorySource,
|
||||||
|
entries: Array<{ name: string; type: 'file' }>,
|
||||||
|
) {
|
||||||
|
const queryOptions =
|
||||||
|
source.type === 'agent'
|
||||||
|
? mocks.sandboxFilesQueryOptions
|
||||||
|
: mocks.workflowSandboxFilesQueryOptions
|
||||||
|
queryOptions.mockImplementation(({ input }: QueryOptionsInput) => ({
|
||||||
|
queryKey: [`${source.type}-sandbox-files`, input, entries],
|
||||||
|
queryFn: async () => ({
|
||||||
|
path: input.query?.path ?? (source.type === 'agent' ? '~/workspace' : '.'),
|
||||||
|
entries,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockFileReadAsBinary(source: AgentWorkingDirectorySource) {
|
||||||
|
const queryOptions =
|
||||||
|
source.type === 'agent'
|
||||||
|
? mocks.sandboxFileReadQueryOptions
|
||||||
|
: mocks.workflowSandboxFileReadQueryOptions
|
||||||
|
queryOptions.mockImplementation(({ input }: QueryOptionsInput) => ({
|
||||||
|
queryKey: [`${source.type}-sandbox-file-read`, input],
|
||||||
|
queryFn: async () => ({
|
||||||
|
binary: true,
|
||||||
|
path: input.query?.path ?? '',
|
||||||
|
text: null,
|
||||||
|
truncated: false,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectedImagePreviewRequest(source: AgentWorkingDirectorySource, path: string) {
|
||||||
|
if (source.type === 'agent') {
|
||||||
|
return {
|
||||||
|
params: { agent_id: source.agentId },
|
||||||
|
body: {
|
||||||
|
caller_type: source.callerType,
|
||||||
|
caller_id: source.callerId,
|
||||||
|
path: `~/${path}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
params: {
|
||||||
|
app_id: source.appId,
|
||||||
|
workflow_run_id: source.workflowRunId,
|
||||||
|
node_id: source.nodeId,
|
||||||
},
|
},
|
||||||
})
|
body: {
|
||||||
const rendered = render(
|
node_execution_id: source.nodeExecutionId,
|
||||||
<QueryClientProvider client={queryClient}>
|
path: `~/${path}`,
|
||||||
<AgentWorkingDirectoryPanel
|
},
|
||||||
open
|
}
|
||||||
onOpenChange={vi.fn()}
|
}
|
||||||
source={{
|
|
||||||
type: 'workflow-node',
|
function fileName(path: string) {
|
||||||
appId: 'app-1',
|
return path.slice(path.lastIndexOf('/') + 1)
|
||||||
workflowRunId: 'run-1',
|
|
||||||
nodeId: 'node-1',
|
|
||||||
nodeExecutionId: 'execution-1',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
)
|
|
||||||
return { ...rendered, queryClient }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('AgentWorkingDirectoryPanel', () => {
|
describe('AgentWorkingDirectoryPanel', () => {
|
||||||
@@ -244,8 +352,8 @@ describe('AgentWorkingDirectoryPanel', () => {
|
|||||||
|
|
||||||
it('should download the selected working directory file from the preview header download action', async () => {
|
it('should download the selected working directory file from the preview header download action', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const upload = createDeferred<{ url: string }>()
|
const download = createDeferred<{ url: string }>()
|
||||||
mocks.sandboxFileUploadMutationFn.mockReturnValueOnce(upload.promise)
|
mocks.sandboxFileDownloadMutationFn.mockReturnValueOnce(download.promise)
|
||||||
renderWorkingDirectoryPanel()
|
renderWorkingDirectoryPanel()
|
||||||
|
|
||||||
await user.click(await screen.findByText('notes.md'))
|
await user.click(await screen.findByText('notes.md'))
|
||||||
@@ -259,12 +367,14 @@ describe('AgentWorkingDirectoryPanel', () => {
|
|||||||
name: /common\.operation\.downloading.*notes\.md/i,
|
name: /common\.operation\.downloading.*notes\.md/i,
|
||||||
})
|
})
|
||||||
expect(downloadingButton.querySelector('.animate-spin')).toBeInTheDocument()
|
expect(downloadingButton.querySelector('.animate-spin')).toBeInTheDocument()
|
||||||
|
await user.click(downloadingButton)
|
||||||
|
expect(mocks.sandboxFileDownloadMutationFn).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
upload.resolve({ url: 'https://example.com/sandbox-file' })
|
download.resolve({ url: 'https://example.com/sandbox-file' })
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mocks.sandboxFileUploadMutationFn).toHaveBeenCalled()
|
expect(mocks.sandboxFileDownloadMutationFn).toHaveBeenCalled()
|
||||||
expect(mocks.sandboxFileUploadMutationFn.mock.calls[0]?.[0]).toEqual({
|
expect(mocks.sandboxFileDownloadMutationFn.mock.calls[0]?.[0]).toEqual({
|
||||||
params: {
|
params: {
|
||||||
agent_id: 'agent-1',
|
agent_id: 'agent-1',
|
||||||
},
|
},
|
||||||
@@ -284,8 +394,8 @@ describe('AgentWorkingDirectoryPanel', () => {
|
|||||||
|
|
||||||
it('should download binary working directory files from the unsupported preview download link', async () => {
|
it('should download binary working directory files from the unsupported preview download link', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const upload = createDeferred<{ url: string }>()
|
const download = createDeferred<{ url: string }>()
|
||||||
mocks.sandboxFileUploadMutationFn.mockReturnValueOnce(upload.promise)
|
mocks.sandboxFileDownloadMutationFn.mockReturnValueOnce(download.promise)
|
||||||
renderWorkingDirectoryPanel()
|
renderWorkingDirectoryPanel()
|
||||||
|
|
||||||
await user.click(await screen.findByText('model.bin'))
|
await user.click(await screen.findByText('model.bin'))
|
||||||
@@ -304,11 +414,11 @@ describe('AgentWorkingDirectoryPanel', () => {
|
|||||||
})
|
})
|
||||||
expect(headerDownloadButton.querySelector('.animate-spin')).not.toBeInTheDocument()
|
expect(headerDownloadButton.querySelector('.animate-spin')).not.toBeInTheDocument()
|
||||||
|
|
||||||
upload.resolve({ url: 'https://example.com/sandbox-file' })
|
download.resolve({ url: 'https://example.com/sandbox-file' })
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mocks.sandboxFileUploadMutationFn).toHaveBeenCalled()
|
expect(mocks.sandboxFileDownloadMutationFn).toHaveBeenCalled()
|
||||||
expect(mocks.sandboxFileUploadMutationFn.mock.calls[0]?.[0]).toEqual({
|
expect(mocks.sandboxFileDownloadMutationFn.mock.calls[0]?.[0]).toEqual({
|
||||||
params: {
|
params: {
|
||||||
agent_id: 'agent-1',
|
agent_id: 'agent-1',
|
||||||
},
|
},
|
||||||
@@ -326,22 +436,22 @@ describe('AgentWorkingDirectoryPanel', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should preview sandbox images with the uploaded file url', async () => {
|
it('should preview sandbox images with the downloaded file url', async () => {
|
||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
const upload = createDeferred<{ url: string }>()
|
const download = createDeferred<{ url: string }>()
|
||||||
mocks.sandboxFileUploadClientPost.mockReturnValueOnce(upload.promise)
|
mocks.sandboxFileDownloadClientPost.mockReturnValueOnce(download.promise)
|
||||||
renderWorkingDirectoryPanel()
|
renderWorkingDirectoryPanel()
|
||||||
|
|
||||||
await user.click(await screen.findByText('chart.png'))
|
await user.click(await screen.findByText('chart.png'))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mocks.sandboxFileUploadClientPost).toHaveBeenCalled()
|
expect(mocks.sandboxFileDownloadClientPost).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
upload.resolve({ url: 'https://example.com/chart.png' })
|
download.resolve({ url: 'https://example.com/chart.png' })
|
||||||
|
|
||||||
const image = await screen.findByAltText('chart.png')
|
const image = await screen.findByAltText('chart.png')
|
||||||
expect(image).toHaveAttribute('src', 'https://example.com/chart.png')
|
expect(image).toHaveAttribute('src', 'https://example.com/chart.png')
|
||||||
expect(mocks.sandboxFileUploadClientPost).toHaveBeenCalledWith({
|
expect(mocks.sandboxFileDownloadClientPost).toHaveBeenCalledWith({
|
||||||
params: {
|
params: {
|
||||||
agent_id: 'agent-1',
|
agent_id: 'agent-1',
|
||||||
},
|
},
|
||||||
@@ -357,27 +467,186 @@ describe('AgentWorkingDirectoryPanel', () => {
|
|||||||
expect(mocks.downloadUrl).not.toHaveBeenCalled()
|
expect(mocks.downloadUrl).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should scope workflow image previews to the exact node execution', async () => {
|
it.each(previewSourceCases)(
|
||||||
const { queryClient } = renderWorkflowWorkingDirectoryPanel()
|
'should refresh $label image previews when caller ownership or path changes',
|
||||||
|
async ({ source, identitySource, imagePaths, previewClient, urls }) => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const [initialImagePath, nextImagePath] = imagePaths
|
||||||
|
const [initialUrl, identityUrl, pathUrl] = urls
|
||||||
|
mockFileListEntries(
|
||||||
|
source,
|
||||||
|
imagePaths.map((name) => ({ name, type: 'file' })),
|
||||||
|
)
|
||||||
|
previewClient
|
||||||
|
.mockResolvedValueOnce({ url: initialUrl })
|
||||||
|
.mockResolvedValueOnce({ url: identityUrl })
|
||||||
|
.mockResolvedValueOnce({ url: pathUrl })
|
||||||
|
const { rerenderPanel } = renderWorkingDirectoryPanel({ source })
|
||||||
|
|
||||||
await waitFor(() => {
|
expect(await screen.findByAltText(fileName(initialImagePath))).toHaveAttribute(
|
||||||
expect(mocks.workflowSandboxFileUploadClientPost).toHaveBeenCalled()
|
'src',
|
||||||
})
|
initialUrl,
|
||||||
|
)
|
||||||
|
expect(previewClient).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
rerenderPanel({ open: true, source: identitySource })
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(previewClient).toHaveBeenCalledTimes(2)
|
||||||
|
expect(screen.getByAltText(fileName(initialImagePath))).toHaveAttribute('src', identityUrl)
|
||||||
|
})
|
||||||
|
expect(previewClient).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
expectedImagePreviewRequest(identitySource, initialImagePath),
|
||||||
|
)
|
||||||
|
|
||||||
|
await user.click(await screen.findByText(fileName(nextImagePath)))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(previewClient).toHaveBeenCalledTimes(3)
|
||||||
|
expect(screen.getByAltText(fileName(nextImagePath))).toHaveAttribute('src', pathUrl)
|
||||||
|
})
|
||||||
|
expect(previewClient).toHaveBeenNthCalledWith(
|
||||||
|
3,
|
||||||
|
expectedImagePreviewRequest(identitySource, nextImagePath),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it.each(previewSourceCases)(
|
||||||
|
'should disable $label image preview requests while closed and for non-images',
|
||||||
|
async ({ source, identitySource, imagePaths, nonImagePath, previewClient, urls }) => {
|
||||||
|
const [imagePath] = imagePaths
|
||||||
|
const [firstUrl] = urls
|
||||||
|
mockFileListEntries(source, [{ name: imagePath, type: 'file' }])
|
||||||
|
previewClient.mockResolvedValueOnce({ url: firstUrl })
|
||||||
|
const { rerenderPanel, unmount } = renderWorkingDirectoryPanel({ source })
|
||||||
|
|
||||||
|
expect(await screen.findByAltText(fileName(imagePath))).toHaveAttribute('src', firstUrl)
|
||||||
|
expect(previewClient).toHaveBeenCalledTimes(1)
|
||||||
|
previewClient.mockClear()
|
||||||
|
|
||||||
|
rerenderPanel({ open: false, source: identitySource })
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByAltText(fileName(imagePath))).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(previewClient).toHaveBeenCalledTimes(0)
|
||||||
|
unmount()
|
||||||
|
|
||||||
|
mockFileListEntries(source, [{ name: nonImagePath, type: 'file' }])
|
||||||
|
mockFileReadAsBinary(source)
|
||||||
|
renderWorkingDirectoryPanel({ source })
|
||||||
|
expect(
|
||||||
|
await screen.findByText('agentV2.agentDetail.configure.files.preview.unsupported'),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
expect(previewClient).toHaveBeenCalledTimes(0)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('should download workflow files from the exact node execution', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const download = createDeferred<{ url: string }>()
|
||||||
|
mocks.workflowSandboxFileDownloadMutationFn.mockReturnValueOnce(download.promise)
|
||||||
|
renderWorkflowWorkingDirectoryPanel()
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
await screen.findByRole('button', {
|
||||||
|
name: /common\.operation\.download.*chart\.png/i,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
queryClient.getQueryCache().find({
|
await screen.findByRole('button', {
|
||||||
queryKey: [
|
name: /common\.operation\.downloading.*chart\.png/i,
|
||||||
'agent-v2',
|
|
||||||
'working-directory',
|
|
||||||
'image-preview',
|
|
||||||
'workflow-node',
|
|
||||||
'app-1',
|
|
||||||
'run-1',
|
|
||||||
'node-1',
|
|
||||||
'execution-1',
|
|
||||||
'chart.png',
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
).toBeDefined()
|
).toBeInTheDocument()
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: /common\.operation\.downloading.*chart\.png/i,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(mocks.workflowSandboxFileDownloadMutationFn).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
download.resolve({ url: 'https://example.com/workflow-sandbox-file' })
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mocks.workflowSandboxFileDownloadMutationFn).toHaveBeenCalled()
|
||||||
|
expect(mocks.workflowSandboxFileDownloadMutationFn.mock.calls[0]?.[0]).toEqual({
|
||||||
|
params: {
|
||||||
|
app_id: 'app-1',
|
||||||
|
workflow_run_id: 'run-1',
|
||||||
|
node_id: 'node-1',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
node_execution_id: 'execution-1',
|
||||||
|
path: '~/chart.png',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(mocks.downloadUrl).toHaveBeenCalledWith({
|
||||||
|
url: 'https://example.com/workflow-sandbox-file',
|
||||||
|
fileName: 'chart.png',
|
||||||
|
})
|
||||||
|
expect(toast.success).toHaveBeenCalledWith('common.operation.downloadSuccess')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear Agent download pending state without reporting success after failure', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const download = createDeferred<{ url: string }>()
|
||||||
|
mocks.sandboxFileDownloadMutationFn.mockReturnValueOnce(download.promise)
|
||||||
|
renderWorkingDirectoryPanel()
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
await screen.findByRole('button', {
|
||||||
|
name: /common\.operation\.download.*report\.md/i,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('button', {
|
||||||
|
name: /common\.operation\.downloading.*report\.md/i,
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
|
||||||
|
download.reject(new Error('download failed'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: /common\.operation\.download.*report\.md/i,
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(mocks.downloadUrl).not.toHaveBeenCalled()
|
||||||
|
expect(toast.success).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear workflow download pending state without reporting success after failure', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const download = createDeferred<{ url: string }>()
|
||||||
|
mocks.workflowSandboxFileDownloadMutationFn.mockReturnValueOnce(download.promise)
|
||||||
|
renderWorkflowWorkingDirectoryPanel()
|
||||||
|
|
||||||
|
await user.click(
|
||||||
|
await screen.findByRole('button', {
|
||||||
|
name: /common\.operation\.download.*chart\.png/i,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('button', {
|
||||||
|
name: /common\.operation\.downloading.*chart\.png/i,
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
|
||||||
|
download.reject(new Error('download failed'))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: /common\.operation\.download.*chart\.png/i,
|
||||||
|
}),
|
||||||
|
).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
expect(mocks.downloadUrl).not.toHaveBeenCalled()
|
||||||
|
expect(toast.success).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+17
-13
@@ -445,18 +445,18 @@ export function AgentWorkingDirectoryPanel({
|
|||||||
},
|
},
|
||||||
retry: false,
|
retry: false,
|
||||||
})
|
})
|
||||||
const agentSandboxUploadMutation = useMutation(
|
const agentSandboxDownloadMutation = useMutation(
|
||||||
consoleQuery.agent.byAgentId.sandbox.files.upload.post.mutationOptions(),
|
consoleQuery.agent.byAgentId.sandbox.files.download.post.mutationOptions(),
|
||||||
)
|
)
|
||||||
const workflowSandboxUploadMutation = useMutation(
|
const workflowSandboxDownloadMutation = useMutation(
|
||||||
consoleQuery.apps.byAppId.workflowRuns.byWorkflowRunId.agentNodes.byNodeId.sandbox.files.upload.post.mutationOptions(),
|
consoleQuery.apps.byAppId.workflowRuns.byWorkflowRunId.agentNodes.byNodeId.sandbox.files.download.post.mutationOptions(),
|
||||||
)
|
)
|
||||||
const { mutateAsync: uploadAgentSandboxFile } = agentSandboxUploadMutation
|
const { mutateAsync: downloadAgentSandboxFile } = agentSandboxDownloadMutation
|
||||||
const isImagePreviewFile = selectedWorkingDirectoryFile?.icon === 'image'
|
const isImagePreviewFile = selectedWorkingDirectoryFile?.icon === 'image'
|
||||||
const selectedWorkingDirectoryFilePath = selectedWorkingDirectoryFile?.id
|
const selectedWorkingDirectoryFilePath = selectedWorkingDirectoryFile?.id
|
||||||
const { mutateAsync: uploadWorkflowSandboxFile } = workflowSandboxUploadMutation
|
const { mutateAsync: downloadWorkflowSandboxFile } = workflowSandboxDownloadMutation
|
||||||
const isFileDownloadPending =
|
const isFileDownloadPending =
|
||||||
agentSandboxUploadMutation.isPending || workflowSandboxUploadMutation.isPending
|
agentSandboxDownloadMutation.isPending || workflowSandboxDownloadMutation.isPending
|
||||||
const isFileReadLoading =
|
const isFileReadLoading =
|
||||||
!!selectedWorkingDirectoryFile && !isImagePreviewFile && fileReadQuery.isPending
|
!!selectedWorkingDirectoryFile && !isImagePreviewFile && fileReadQuery.isPending
|
||||||
const imagePreviewQuery = useQuery({
|
const imagePreviewQuery = useQuery({
|
||||||
@@ -476,7 +476,7 @@ export function AgentWorkingDirectoryPanel({
|
|||||||
throw new Error('Missing selected working directory file')
|
throw new Error('Missing selected working directory file')
|
||||||
|
|
||||||
if (source.type === 'agent') {
|
if (source.type === 'agent') {
|
||||||
return consoleClient.agent.byAgentId.sandbox.files.upload.post({
|
return consoleClient.agent.byAgentId.sandbox.files.download.post({
|
||||||
params: {
|
params: {
|
||||||
agent_id: source.agentId,
|
agent_id: source.agentId,
|
||||||
},
|
},
|
||||||
@@ -488,7 +488,7 @@ export function AgentWorkingDirectoryPanel({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return consoleClient.apps.byAppId.workflowRuns.byWorkflowRunId.agentNodes.byNodeId.sandbox.files.upload.post(
|
return consoleClient.apps.byAppId.workflowRuns.byWorkflowRunId.agentNodes.byNodeId.sandbox.files.download.post(
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
app_id: source.appId,
|
app_id: source.appId,
|
||||||
@@ -511,7 +511,7 @@ export function AgentWorkingDirectoryPanel({
|
|||||||
if (source.type === 'agent') {
|
if (source.type === 'agent') {
|
||||||
setDownloadActionLoadingTarget(action)
|
setDownloadActionLoadingTarget(action)
|
||||||
try {
|
try {
|
||||||
const result = await uploadAgentSandboxFile({
|
const result = await downloadAgentSandboxFile({
|
||||||
params: {
|
params: {
|
||||||
agent_id: source.agentId,
|
agent_id: source.agentId,
|
||||||
},
|
},
|
||||||
@@ -523,6 +523,8 @@ export function AgentWorkingDirectoryPanel({
|
|||||||
})
|
})
|
||||||
downloadUrl({ url: result.url, fileName: selectedWorkingDirectoryFile.name })
|
downloadUrl({ url: result.url, fileName: selectedWorkingDirectoryFile.name })
|
||||||
toast.success(tCommon(($) => $['operation.downloadSuccess']))
|
toast.success(tCommon(($) => $['operation.downloadSuccess']))
|
||||||
|
} catch {
|
||||||
|
// The generated client reports the mutation failure through its shared error handler.
|
||||||
} finally {
|
} finally {
|
||||||
setDownloadActionLoadingTarget(null)
|
setDownloadActionLoadingTarget(null)
|
||||||
}
|
}
|
||||||
@@ -531,7 +533,7 @@ export function AgentWorkingDirectoryPanel({
|
|||||||
|
|
||||||
setDownloadActionLoadingTarget(action)
|
setDownloadActionLoadingTarget(action)
|
||||||
try {
|
try {
|
||||||
const result = await uploadWorkflowSandboxFile({
|
const result = await downloadWorkflowSandboxFile({
|
||||||
params: {
|
params: {
|
||||||
app_id: source.appId,
|
app_id: source.appId,
|
||||||
workflow_run_id: source.workflowRunId,
|
workflow_run_id: source.workflowRunId,
|
||||||
@@ -544,6 +546,8 @@ export function AgentWorkingDirectoryPanel({
|
|||||||
})
|
})
|
||||||
downloadUrl({ url: result.url, fileName: selectedWorkingDirectoryFile.name })
|
downloadUrl({ url: result.url, fileName: selectedWorkingDirectoryFile.name })
|
||||||
toast.success(tCommon(($) => $['operation.downloadSuccess']))
|
toast.success(tCommon(($) => $['operation.downloadSuccess']))
|
||||||
|
} catch {
|
||||||
|
// The generated client reports the mutation failure through its shared error handler.
|
||||||
} finally {
|
} finally {
|
||||||
setDownloadActionLoadingTarget(null)
|
setDownloadActionLoadingTarget(null)
|
||||||
}
|
}
|
||||||
@@ -553,8 +557,8 @@ export function AgentWorkingDirectoryPanel({
|
|||||||
selectedWorkingDirectoryFile,
|
selectedWorkingDirectoryFile,
|
||||||
source,
|
source,
|
||||||
tCommon,
|
tCommon,
|
||||||
uploadAgentSandboxFile,
|
downloadAgentSandboxFile,
|
||||||
uploadWorkflowSandboxFile,
|
downloadWorkflowSandboxFile,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user