fix: omit source IDs from ifpkg manifests (#43824)

This commit is contained in:
林玮 (Jade Lin)
2026-10-13 02:38:14 +00:00
committed by GitHub
parent da9e82542c
commit dad74d7846
9 changed files with 37 additions and 53 deletions
@@ -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,
)
)
+3 -12
View File
@@ -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)
+1 -1
View File
@@ -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))