mirror of
https://github.com/langgenius/dify.git
synced 2026-10-16 15:35:32 +08:00
fix: omit source IDs from ifpkg manifests (#43824)
This commit is contained in:
@@ -25,7 +25,6 @@ from services.agent.errors import RosterAgentPackageExportFailedError, RosterAge
|
||||
from services.agent.roster_package_entities import (
|
||||
AgentPackageResources,
|
||||
PackageIcon,
|
||||
RosterAgentPackageAudit,
|
||||
RosterAgentPackageFile,
|
||||
RosterAgentPackageSkill,
|
||||
)
|
||||
@@ -44,7 +43,6 @@ class _SkillSource:
|
||||
id: str
|
||||
scope: Literal["agent_config", "workspace"]
|
||||
name: str
|
||||
audit_ref: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -52,7 +50,6 @@ class _FileSource:
|
||||
path: str
|
||||
storage_key: str
|
||||
id: str
|
||||
audit_ref: str
|
||||
|
||||
|
||||
class AgentPackageResourceExporter:
|
||||
@@ -150,7 +147,6 @@ class AgentPackageResourceExporter:
|
||||
id=resource_id,
|
||||
scope="workspace",
|
||||
name=archive.name,
|
||||
audit_ref=archive.version_id,
|
||||
)
|
||||
)
|
||||
package.workspace_skills.append(
|
||||
@@ -215,7 +211,6 @@ class AgentPackageResourceExporter:
|
||||
path=item.path,
|
||||
size=size,
|
||||
sha256=digest,
|
||||
audit=RosterAgentPackageAudit(ref=item.audit_ref),
|
||||
)
|
||||
)
|
||||
for file_source in file_sources:
|
||||
@@ -226,7 +221,6 @@ class AgentPackageResourceExporter:
|
||||
path=file_source.path,
|
||||
size=size,
|
||||
sha256=digest,
|
||||
audit=RosterAgentPackageAudit(ref=file_source.audit_ref),
|
||||
)
|
||||
)
|
||||
icons = []
|
||||
@@ -284,7 +278,6 @@ class AgentPackageResourceExporter:
|
||||
path=source.path,
|
||||
size=member_size,
|
||||
sha256=member_digest,
|
||||
audit=RosterAgentPackageAudit(ref=source.audit_ref),
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -294,7 +287,6 @@ class AgentPackageResourceExporter:
|
||||
path=source.path,
|
||||
size=member_size,
|
||||
sha256=member_digest,
|
||||
audit=RosterAgentPackageAudit(ref=source.audit_ref),
|
||||
)
|
||||
)
|
||||
resources[ref] = AgentPackageResources(skills=skills, files=files)
|
||||
@@ -332,7 +324,6 @@ class AgentPackageResourceExporter:
|
||||
id=resource_id,
|
||||
scope="agent_config",
|
||||
name=skill_ref.name,
|
||||
audit_ref=tool_file.id,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -356,14 +347,12 @@ class AgentPackageResourceExporter:
|
||||
raise RosterAgentPackageExportFailedError(f"Config file {file_ref.name!r} payload is unavailable")
|
||||
storage_key = tool_file.file_key
|
||||
mime_type = file_ref.mime_type or tool_file.mimetype
|
||||
audit_ref = tool_file.id
|
||||
else:
|
||||
upload_file = upload_files.get(file_ref.file_id)
|
||||
if upload_file is None:
|
||||
raise RosterAgentPackageExportFailedError(f"Config file {file_ref.name!r} payload is unavailable")
|
||||
storage_key = upload_file.key
|
||||
mime_type = file_ref.mime_type or upload_file.mime_type
|
||||
audit_ref = upload_file.id
|
||||
portable_file_ref["file_id"] = resource_id
|
||||
portable_file_ref["is_missing"] = False
|
||||
portable_file_ref["mime_type"] = mime_type or "application/octet-stream"
|
||||
@@ -372,7 +361,6 @@ class AgentPackageResourceExporter:
|
||||
path=path,
|
||||
storage_key=storage_key,
|
||||
id=resource_id,
|
||||
audit_ref=audit_ref,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Strict data contract for portable Roster Agent archives."""
|
||||
"""Data contract and integrity validation for portable Roster Agent archives."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -25,19 +25,12 @@ class RosterAgentPackageMember:
|
||||
sha256: str
|
||||
|
||||
|
||||
class RosterAgentPackageAudit(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
ref: str = Field(min_length=1, max_length=255)
|
||||
|
||||
|
||||
class _RosterAgentPackageResource(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
path: str = Field(min_length=1, max_length=255)
|
||||
size: int = Field(ge=0)
|
||||
sha256: str
|
||||
audit: RosterAgentPackageAudit | None = None
|
||||
|
||||
@field_validator("sha256")
|
||||
@classmethod
|
||||
@@ -95,7 +88,7 @@ class PackageIcon(_RosterAgentPackageResource):
|
||||
class AgentPackageResources(BaseModel):
|
||||
"""Resources belonging to one portable Agent, independent of its container."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
skills: list[RosterAgentPackageSkill] = Field(default_factory=list)
|
||||
files: list[RosterAgentPackageFile] = Field(default_factory=list)
|
||||
@@ -164,7 +157,6 @@ class RosterAgentPackageManifest(AgentPackageResources):
|
||||
|
||||
format: Literal["dify.roster-agent"]
|
||||
format_version: Literal[1]
|
||||
audit: RosterAgentPackageAudit | None = None
|
||||
icons: list[PackageIcon] = Field(default_factory=list)
|
||||
apps: list[RosterAgentPackageApp] = Field(min_length=1)
|
||||
|
||||
@@ -251,7 +243,6 @@ __all__ = [
|
||||
"PreparedPackageArchive",
|
||||
"PreparedRosterAgentPackage",
|
||||
"RosterAgentPackageApp",
|
||||
"RosterAgentPackageAudit",
|
||||
"RosterAgentPackageExport",
|
||||
"RosterAgentPackageFile",
|
||||
"RosterAgentPackageManifest",
|
||||
|
||||
@@ -45,7 +45,6 @@ from services.agent.roster_package_entities import (
|
||||
ROSTER_AGENT_PACKAGE_FORMAT,
|
||||
ROSTER_AGENT_PACKAGE_FORMAT_VERSION,
|
||||
RosterAgentPackageApp,
|
||||
RosterAgentPackageAudit,
|
||||
RosterAgentPackageExport,
|
||||
RosterAgentPackageManifest,
|
||||
)
|
||||
@@ -68,8 +67,8 @@ class RosterAgentPackageExporter:
|
||||
self._dependency_provider = dependency_provider or DependenciesAnalysisService.generate_dependencies
|
||||
|
||||
def export(self, *, tenant_id: str, agent_id: str, version_id: UUID | None) -> RosterAgentPackageExport:
|
||||
app, resources, audit = self.collect(tenant_id=tenant_id, agent_id=agent_id, version_id=version_id)
|
||||
return self._build_archive(app=app, audit=audit, resources=resources)
|
||||
app, resources = self.collect(tenant_id=tenant_id, agent_id=agent_id, version_id=version_id)
|
||||
return self._build_archive(app=app, resources=resources)
|
||||
|
||||
def export_yaml(self, *, tenant_id: str, agent_id: str, version_id: UUID) -> str:
|
||||
"""Reuse the standard YAML export semantics, including omitted asset references."""
|
||||
@@ -103,7 +102,7 @@ class RosterAgentPackageExporter:
|
||||
|
||||
def collect(
|
||||
self, *, tenant_id: str, agent_id: str, version_id: UUID | None
|
||||
) -> tuple[AgentAppDsl, AgentPackageResourceExporter, RosterAgentPackageAudit]:
|
||||
) -> tuple[AgentAppDsl, AgentPackageResourceExporter]:
|
||||
"""Collect configuration and resource references without serializing a package."""
|
||||
resources = AgentPackageResourceExporter(storage_backend=self._storage)
|
||||
with session_factory.create_session() as session:
|
||||
@@ -158,19 +157,17 @@ class RosterAgentPackageExporter:
|
||||
site_data = SiteDsl.from_site(site).model_dump(mode="json")
|
||||
resources.collect_icon(session=session, tenant_id=tenant_id, metadata=site_data)
|
||||
app.site = SiteDsl.model_validate(site_data)
|
||||
audit = RosterAgentPackageAudit(ref=agent.id)
|
||||
dependency_ids = extract_agent_soul_dependencies(package.soul)
|
||||
|
||||
resources.collect_workspace_skills()
|
||||
app.dependencies = self._dependency_provider(tenant_id, dependency_ids)
|
||||
return app, resources, audit
|
||||
return app, resources
|
||||
|
||||
def _build_archive(
|
||||
self,
|
||||
*,
|
||||
app: AgentAppDsl,
|
||||
resources: AgentPackageResourceExporter,
|
||||
audit: RosterAgentPackageAudit | None = None,
|
||||
) -> RosterAgentPackageExport:
|
||||
# Ownership is transferred to RosterAgentPackageExport.
|
||||
output = cast(
|
||||
@@ -188,7 +185,6 @@ class RosterAgentPackageExporter:
|
||||
manifest = RosterAgentPackageManifest(
|
||||
format=ROSTER_AGENT_PACKAGE_FORMAT,
|
||||
format_version=ROSTER_AGENT_PACKAGE_FORMAT_VERSION,
|
||||
audit=audit,
|
||||
apps=[
|
||||
RosterAgentPackageApp(
|
||||
path="app.yaml", size=len(app_bytes), sha256=hashlib.sha256(app_bytes).hexdigest()
|
||||
|
||||
@@ -82,7 +82,7 @@ class RosterAgentPackageImporter:
|
||||
icon_background: str | None = None,
|
||||
) -> RosterAgentPackageImportResult:
|
||||
"""Import a catalog-admitted published source without an outer archive."""
|
||||
app, resources, _ = RosterAgentPackageExporter().collect(
|
||||
app, resources = RosterAgentPackageExporter().collect(
|
||||
tenant_id=source.tenant_id, agent_id=source.agent_id, version_id=source.version_id
|
||||
)
|
||||
payloads, resource_index, icons = resources.read_local_resources(app.agent.package_ref)
|
||||
|
||||
@@ -43,7 +43,7 @@ from services.entities.site_dsl import SiteDsl
|
||||
class AppPackageManifest(BaseModel):
|
||||
"""Index Agent resources by the same package references used in the workflow DSL."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
format: Literal["dify.app"]
|
||||
format_version: Literal[1]
|
||||
|
||||
@@ -703,22 +703,22 @@ def test_import_materializes_agent_resources_and_unpublished_draft(
|
||||
assert _count(session, AgentDebugConversation) == 1
|
||||
|
||||
|
||||
def test_import_rejects_binary_dependencies_before_side_effects(
|
||||
def test_import_ignores_unknown_resource_metadata(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
storage = _MemoryStorage()
|
||||
importer = RosterAgentPackageImporter(storage_backend=storage)
|
||||
|
||||
with pytest.raises(InvalidRosterAgentPackageError, match="manifest is invalid"):
|
||||
importer.import_package(
|
||||
source=io.BytesIO(_package(binary_dependency=True)),
|
||||
tenant_id="tenant-1",
|
||||
account=_account(),
|
||||
)
|
||||
result = importer.import_package(
|
||||
source=io.BytesIO(_package(binary_dependency=True)),
|
||||
tenant_id="tenant-1",
|
||||
account=_account(),
|
||||
)
|
||||
|
||||
assert storage.files == {}
|
||||
assert len(storage.files) == 4
|
||||
with sqlite_session_factory() as session:
|
||||
assert _count(session, App) == 0
|
||||
assert session.get(App, result.app_id) is not None
|
||||
assert _count(session, App) == 1
|
||||
|
||||
|
||||
def test_import_marks_unavailable_knowledge_for_rebinding(
|
||||
|
||||
@@ -650,7 +650,6 @@ def test_preflight_rejects_aggregate_nested_skill_expansion(monkeypatch: pytest.
|
||||
id=item.id,
|
||||
scope=item.scope,
|
||||
name=item.name,
|
||||
audit_ref="source",
|
||||
)
|
||||
for item in manifest.skills
|
||||
]
|
||||
@@ -738,7 +737,6 @@ def test_export_rejects_unusable_or_oversized_skill_payload(
|
||||
id="s_000001",
|
||||
scope="agent_config",
|
||||
name="research",
|
||||
audit_ref="source",
|
||||
)
|
||||
app = _package_app()
|
||||
app.package.soul.config_files = []
|
||||
@@ -981,13 +979,15 @@ def test_export_accepts_legacy_agent_and_preserves_caller_transaction(
|
||||
assert exported_app.package.soul.config_skills[1].file_id == ""
|
||||
assert exported_app.package.soul.config_files[1].is_missing is True
|
||||
assert exported_app.package.soul.config_files[1].file_id == ""
|
||||
assert set(manifest_data) == {"format", "format_version", "audit", "apps", "skills", "files"}
|
||||
assert set(manifest_data) == {"format", "format_version", "apps", "skills", "files"}
|
||||
for source_id in (agent.id, skill_file.id, config_file.id):
|
||||
assert source_id.encode() not in archive.read("manifest.yaml")
|
||||
assert source_id.encode() not in archive.read("app.yaml")
|
||||
assert set(manifest_data["files"][0]) == {
|
||||
"id",
|
||||
"path",
|
||||
"size",
|
||||
"sha256",
|
||||
"audit",
|
||||
}
|
||||
assert manifest_data["apps"] == [
|
||||
{
|
||||
@@ -1163,7 +1163,6 @@ def test_export_uses_current_workspace_skill_bindings(
|
||||
"path",
|
||||
"size",
|
||||
"sha256",
|
||||
"audit",
|
||||
"scope",
|
||||
"name",
|
||||
}
|
||||
@@ -1294,14 +1293,17 @@ def test_export_rejects_unavailable_agent_versions(sqlite_session: Session, sour
|
||||
AppDslService.export_dsl(app_model, session=sqlite_session, version_id=version_id)
|
||||
|
||||
|
||||
def test_manifest_yaml_is_strict() -> None:
|
||||
def test_manifest_yaml_ignores_unknown_fields() -> None:
|
||||
skill_payload = _skill_archive()
|
||||
file_payload = b"pdf-content"
|
||||
manifest = _manifest(skill_payload=skill_payload, file_payload=file_payload).model_dump(mode="json")
|
||||
expected = _manifest(skill_payload=skill_payload, file_payload=file_payload)
|
||||
manifest = expected.model_dump(mode="json")
|
||||
manifest["unexpected"] = True
|
||||
for group in ("apps", "skills", "files"):
|
||||
for resource in manifest[group]:
|
||||
resource["unexpected"] = True
|
||||
|
||||
with pytest.raises(ValidationError, match="Extra inputs are not permitted"):
|
||||
RosterAgentPackageManifest.model_validate(yaml.safe_load(yaml.safe_dump(manifest)))
|
||||
assert RosterAgentPackageManifest.model_validate(yaml.safe_load(yaml.safe_dump(manifest))) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -272,6 +272,13 @@ def test_package_round_trip_restores_bound_agents_and_resources(
|
||||
app = _source(sqlite_session, storage, mode=mode, published=published)
|
||||
with AppPackageService().export_app(app_model=app, workflow_id="workflow-1" if published else None) as exported:
|
||||
content = exported.archive.read()
|
||||
with zipfile.ZipFile(io.BytesIO(content)) as archive:
|
||||
manifest = yaml.safe_load(archive.read("manifest.yaml"))
|
||||
assert all(
|
||||
"audit" not in resource
|
||||
for group in manifest["agent_resources"].values()
|
||||
for resource in [*group["skills"], *group["files"]]
|
||||
)
|
||||
prepared = AppPackageService().read_package(io.BytesIO(content))
|
||||
assert prepared is not None
|
||||
with prepared:
|
||||
|
||||
@@ -44,7 +44,7 @@ def test_round_trip_retains_exact_dsl_and_defers_version_handling(mode: str, ver
|
||||
assert package.archive.closed
|
||||
|
||||
|
||||
@pytest.mark.parametrize("overrides", [{"format": "unknown"}, {"format_version": 2}, {"apps": []}, {"files": []}])
|
||||
@pytest.mark.parametrize("overrides", [{"format": "unknown"}, {"format_version": 2}, {"apps": []}])
|
||||
def test_rejects_unsupported_manifests(overrides: dict[str, object]) -> None:
|
||||
with pytest.raises(InvalidRosterAgentPackageError, match="manifest"):
|
||||
AppPackageService().read_package(_archive("kind: app\napp: {mode: workflow}\n", **overrides))
|
||||
|
||||
Reference in New Issue
Block a user