diff --git a/README.md b/README.md index ad025fb..ce80039 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,40 @@ surface for older clients. New clients merge the sidecar through a deterministic adapter. Any field repeated in both files must have the same value; the validator rejects drift rather than choosing one copy silently. +Agent listings support exactly one of `agents//agent.json` (inline metadata) +or `agents//entry.json` (an external pointer), alongside the sidecar. Missing +or simultaneous primary files are rejected. For a pointer, `entry.id` and sidecar +`identity.id` use the catalog directory slug and `identity.kind` is `agent`; the +upstream AgentFS `agent.json.id` remains its own UUID and must not be rewritten. + +Agent pointers first pass the complete raw client contract in +[`schemas/market-agent-entry.client.schema.json`](schemas/market-agent-entry.client.schema.json), +exported from `marketAgentEntrySchema` in the DesireCore repository at commit +`18bbb86f62e1288b1f945209bed74ec72620a9d4`. The schema's `$comment` records the +source blob as well. Refresh this generated snapshot from the TypeScript export +when changing client compatibility; do not replace it with permissive sidecar +validation. Version fields keep their original types and the client's supported +format. Installation/update policies must either both be absent (effective +`market/market`) or form a complete supported pair; the sidecar must preserve +that effective pair. + +Agent pointer `latestVersion` maps to sidecar `release.version`; optional +`requiredClientVersion`, `installPolicy`, and `updatePolicy` must agree with the +sidecar compatibility/spec fields. Pointer source fields must describe the same +artifact as `provenance.content`, and `maintainer` maps to `upstreamMaintainer`. +An installable Agent pointer must itself pin `source.ref` (Git) or `source.sha256` +(Web/ZIP); an immutable ref supplied only by the sidecar cannot pin a mutable +entry. Existing immutable-source, license, governance-review and complete-coverage +checks still apply. Agent pointers do not receive the built-in Skill exceptions. + +Agent 目录必须在 `agent.json` 内联元数据和 `entry.json` 外部指针中二选一,并提供 sidecar。 +Pointer 原始 JSON 先通过固定客户端提交导出的完整 Schema;版本类型与格式、来源路径和策略组合不能由 sidecar 掩盖。 +Pointer 的目录 slug、`entry.id`、sidecar `identity.id` 必须一致;上游 AgentFS 的 UUID 不改写。 +安装/更新策略双缺省时有效值仍是 `market/market`,sidecar 不得将其改成系统条目。 +`latestVersion`、最低客户端版本和安装/更新策略须与 sidecar 对齐;来源必须是同一个制品。 +可安装指针自身必须固定 Git ref 或 Web/ZIP 摘要,不能只在 sidecar 宣称不可变版本。 +现有许可、治理审查、不可变来源和完整覆盖门禁继续有效,不适用内置 Skill 的宽松例外。 + The sidecar records source-owned presentation, release, timestamp, content provenance, governance, compatibility, and type-specific facts. It deliberately cannot declare `catalogSourceId`, catalog commit/path/trust, effective official diff --git a/schemas/market-agent-entry.client.schema.json b/schemas/market-agent-entry.client.schema.json new file mode 100644 index 0000000..8fcf841 --- /dev/null +++ b/schemas/market-agent-entry.client.schema.json @@ -0,0 +1,260 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": [ + "id", + "name", + "stewardship", + "license", + "redistribution", + "source" + ], + "properties": { + "id": { + "type": "string", + "description": "条目唯一标识符,使用小写连字符格式,如 \"flyai-skill\"", + "pattern": "^[a-z0-9-]+$" + }, + "name": { + "type": "string", + "description": "默认显示名称(源 locale),本地化变体见 i18n" + }, + "category": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z0-9-]+$", + "description": "市场项目分类(slug 格式),由仓库 categories.json 定义" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + }, + "description": "标签列表,用于搜索和分类" + }, + "icon": { + "type": "string", + "description": "市场列表展示的内联 SVG 图标。Pointer 技能将其随离线元数据返回。" + }, + "latestVersion": { + "type": "string", + "description": "市场登记的最新版本(semver)", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "i18n": { + "type": "object", + "description": "按 locale 提供的本地化展示元数据(仅 name/shortDesc,轻量)", + "additionalProperties": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "该 locale 下的显示名称" + }, + "shortDesc": { + "type": "string", + "description": "该 locale 下的简短描述" + } + }, + "additionalProperties": false + } + }, + "maintainer": { + "type": "object", + "required": [ + "name", + "verified" + ], + "properties": { + "name": { + "type": "string", + "description": "维护者名称,如 \"DesireCore Official\"" + }, + "verified": { + "type": "boolean", + "description": "是否官方认证维护者,认证后显示蓝色勾选标记" + }, + "account": { + "type": "string", + "description": "维护方账号标识,如 GitHub 组织/用户名 \"desirecore\"" + }, + "url": { + "type": "string", + "description": "维护方主页或仓库 URL" + } + }, + "additionalProperties": false + }, + "stewardship": { + "type": "string", + "enum": [ + "official", + "partner", + "community", + "pointer" + ], + "description": "维护方分类:official(官方)/ partner(合作伙伴官方)/ community(社区已收录)/ pointer(仅指针,不分发内容)" + }, + "license": { + "type": "string", + "minLength": 1, + "description": "许可证:SPDX id(MIT/Apache-2.0/MIT-0…)或特殊值 not-declared / source-available / packaged-distribution" + }, + "redistribution": { + "type": "string", + "enum": [ + "allowed", + "source-pointer-only", + "verify-package-terms" + ], + "description": "再分发策略:allowed(可分发)/ source-pointer-only(仅指针,安装时拉取)/ verify-package-terms(需核验条款)" + }, + "source": { + "type": "object", + "required": [ + "kind", + "repoUrl" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "git", + "web", + "zip" + ], + "description": "内容来源类型:git(仓库)/ web(网页或文件)/ zip(打包产物)" + }, + "repoUrl": { + "type": "string", + "description": "源仓库 Git URL 或资源 URL,如 https://github.com/desirecore/skill-foo.git" + }, + "repoBranch": { + "type": "string", + "description": "源仓库分支名称,默认 main(kind=git 时有效)", + "default": "main" + }, + "path": { + "type": "string", + "pattern": "^(?:$|(?![A-Za-z]:[\\\\/])(?![\\\\/])(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$))[^\\u0000]+)$", + "description": "内容在源仓库中的安全相对子路径,如 \"skills/foo\";为空表示仓库根目录。禁止绝对路径、盘符与 .. 路径段。" + }, + "ref": { + "type": "string", + "description": "锁定的 commit SHA / tag,用于可复现安装;为空表示取分支 HEAD" + }, + "sha256": { + "type": "string", + "pattern": "^[0-9a-fA-F]{64}$", + "description": "web/zip 资源原始响应体的 SHA-256;能力自动安装要求用它绑定发现时的不可变字节。" + } + }, + "additionalProperties": false + }, + "requiredClientVersion": { + "type": "string", + "description": "此条目要求的最低客户端版本(semver),低于此版本时提示升级", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "avatar": { + "type": "object", + "required": [ + "t", + "bg" + ], + "properties": { + "t": { + "type": "string", + "description": "头像显示的文字(通常为一个汉字)", + "maxLength": 2 + }, + "bg": { + "type": "string", + "description": "头像背景 CSS 渐变值,如 linear-gradient(135deg, #007AFF, #005ECB)" + }, + "image": { + "type": "object", + "description": "图片头像:市场仓库中该条目目录内的位图(如 \"assets/avatar.webp\")。存在且文件可读时优先于 t + bg 渲染,否则自动回落——所以 t 与 bg 仍为必填,它们是确定的回落形态。仅支持 PNG / JPEG / WebP。注意:这是**市场展示**元数据,与安装后 agent.json 的 avatar.image 是两套,安装时不迁移。", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string", + "description": "相对于该市场条目目录的图片路径。禁止绝对路径和 .. 路径穿越", + "minLength": 5, + "maxLength": 240, + "pattern": "^(?![\\\\/])(?![A-Za-z]:)(?!.*(?:^|[\\\\/])\\.\\.(?:[\\\\/]|$)).+\\.(?:png|jpe?g|webp)$" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "installPolicy": { + "type": "string", + "enum": [ + "market", + "system" + ], + "description": "Agent 安装治理策略。system 条目只用于发现与展示,禁止 Market 安装、覆盖或卸载其运行目录。" + }, + "updatePolicy": { + "type": "string", + "enum": [ + "market", + "repository" + ], + "description": "Agent 更新策略。repository 表示 latestVersion 与更新操作以 source/已安装 Agent 的 Git 仓库为准。" + } + }, + "oneOf": [ + { + "not": { + "anyOf": [ + { + "required": [ + "installPolicy" + ] + }, + { + "required": [ + "updatePolicy" + ] + } + ] + } + }, + { + "required": [ + "installPolicy", + "updatePolicy" + ], + "properties": { + "installPolicy": { + "const": "market" + }, + "updatePolicy": { + "const": "market" + } + } + }, + { + "required": [ + "installPolicy", + "updatePolicy" + ], + "properties": { + "installPolicy": { + "const": "system" + }, + "updatePolicy": { + "const": "repository" + } + } + } + ], + "additionalProperties": false, + "$comment": "Generated from desirecore/desirecore packages/schemas/src/market.ts#marketAgentEntrySchema at commit 18bbb86f62e1288b1f945209bed74ec72620a9d4, source blob e3624f5607dba3cfdf88026894d17f0e6954a4ed. Keep the complete client contract; regenerate from that export instead of editing fields manually." +} diff --git a/scripts/catalog/test_validate_catalog_metadata.py b/scripts/catalog/test_validate_catalog_metadata.py index 14d3f46..a4d0bf9 100644 --- a/scripts/catalog/test_validate_catalog_metadata.py +++ b/scripts/catalog/test_validate_catalog_metadata.py @@ -99,6 +99,7 @@ class CatalogMetadataValidatorTests(unittest.TestCase): self.root = Path(self.tempdir.name) (self.root / "schemas").mkdir() shutil.copyfile(SOURCE_SCHEMA, self.root / "schemas" / SOURCE_SCHEMA.name) + shutil.copyfile(SOURCE_SCHEMA.with_name(VALIDATOR.AGENT_ENTRY_SCHEMA_NAME), self.root / "schemas" / VALIDATOR.AGENT_ENTRY_SCHEMA_NAME) (self.root / "agents").mkdir() (self.root / "skills" / "example-skill").mkdir(parents=True) self.write_json( @@ -174,6 +175,201 @@ class CatalogMetadataValidatorTests(unittest.TestCase): manifest["stats"]["totalAgents"] = 1 self.write_json(self.root / "manifest.json", manifest) + def write_agent_pointer_case(self, mutate_entry=None, mutate_sidecar=None) -> tuple[Path, Path]: + agent_dir = self.root / "agents" / "example-agent" + entry = valid_entry() + entry.update( + id="example-agent", name="Example Agent", latestVersion="1.2.3", + requiredClientVersion="10.0.0", installPolicy="market", updatePolicy="market", + icon='', + ) + entry["i18n"] = { + "zh-CN": {"name": "示例智能体", "shortDesc": "通用示例智能体"}, + "en-US": {"name": "Example Agent", "shortDesc": "General-purpose example agent"}, + } + entry["source"]["repoUrl"] = "https://example.com/example-agent.git" + sidecar = valid_sidecar() + sidecar["identity"] = {"kind": "agent", "id": "example-agent"} + sidecar["presentation"]["i18n"] = { + locale: {"name": value["name"], "summary": value["shortDesc"]} + for locale, value in entry["i18n"].items() + } + sidecar["release"] = {"state": "known", "version": "1.2.3", "versionScheme": "semver"} + sidecar["provenance"]["content"]["url"] = entry["source"]["repoUrl"] + sidecar["timestamps"]["reviewedAt"] = {"state": "known", "value": "2026-08-30", "precision": "day"} + sidecar["governance"].update( + availability="installable", + compliance={"licenseEvidencePath": "LICENSE", "reviewedRef": "a" * 40, + "reviewedAt": "2026-08-30", "reviewedBy": "Example Reviewer", "upstreamEndorsed": False}, + ) + sidecar["governance"]["license"]["evidencePath"] = "LICENSE" + sidecar["compatibility"]["requiredClientVersion"] = "10.0.0" + sidecar["spec"] = {"kind": "agent", "installPolicy": "market", "updatePolicy": "market"} + if mutate_entry: + mutate_entry(entry) + if mutate_sidecar: + mutate_sidecar(sidecar) + entry_path = agent_dir / "entry.json" + sidecar_path = agent_dir / VALIDATOR.SIDECAR_NAME + self.write_json(entry_path, entry) + self.write_json(sidecar_path, sidecar) + self.write_json(self.root / "manifest.json", { + "supportedLocales": ["zh-CN", "en-US"], "stats": {"totalAgents": 1, "totalSkills": 1}, + }) + return entry_path, sidecar_path + + def test_accepts_complete_installable_agent_pointer_without_inline_agent(self) -> None: + entry_path, _ = self.write_agent_pointer_case() + report = self.validate(require_complete=True) + self.assertFalse(report.has_errors, report.issues) + self.assertFalse(entry_path.with_name("agent.json").exists()) + self.assertEqual(1, report.stats["agents"]) + self.assertEqual(2, report.stats["sidecars"]) + + def test_listing_only_agent_pointer_allows_empty_root_path_and_unpinned_ref(self) -> None: + def metadata_change(payload): + payload["provenance"]["content"].pop("ref") + payload["governance"]["availability"] = "listing-only" + + self.write_agent_pointer_case( + lambda payload: payload["source"].update(path="", ref=""), metadata_change, + ) + report = self.validate(require_complete=True) + self.assertFalse(report.has_errors, report.issues) + + def test_accepts_agent_web_and_zip_pointers_with_matching_byte_digest(self) -> None: + for kind in ("web", "zip"): + with self.subTest(kind=kind): + def entry_change(payload): + payload["source"] = {"kind": kind, "repoUrl": "https://example.com/agent.zip", "sha256": "b" * 64} + + def metadata_change(payload): + payload["provenance"]["content"] = {"kind": kind, "url": "https://example.com/agent.zip", "sha256": "b" * 64} + payload["governance"]["compliance"]["reviewedRef"] = "b" * 64 + + self.write_agent_pointer_case(entry_change, metadata_change) + report = self.validate(require_complete=True) + self.assertFalse(report.has_errors, report.issues) + + def test_rejects_agent_pointer_sidecar_conflicts(self) -> None: + mutations = { + "identity": lambda p: p["identity"].update(id="other-agent"), + "kind": lambda p: (p["identity"].update(kind="skill"), p.update(spec={"kind": "skill"})), + "version": lambda p: p["release"].update(version="9.9.9"), + "source-kind": lambda p: p["provenance"]["content"].update(kind="web"), + "source-url": lambda p: p["provenance"]["content"].update(url="https://example.com/other.git"), + "source-ref": lambda p: p["provenance"]["content"].update(ref="b" * 40), + "source-path": lambda p: p["provenance"]["content"].update(path="another-agent"), + "source-digest": lambda p: p["provenance"]["content"].update(sha256="b" * 64), + "category": lambda p: p["presentation"].update(category="research"), + "tags": lambda p: p["presentation"].update(tags=["other"]), + "summary": lambda p: p["presentation"]["i18n"]["en-US"].update(summary="Different"), + "license": lambda p: p["governance"]["license"].update(value="Apache-2.0"), + "redistribution": lambda p: p["governance"].update(redistribution="source-pointer-only"), + "stewardship": lambda p: p["governance"].update(stewardship="official"), + "maintainer": lambda p: p["governance"]["upstreamMaintainer"].update(name="Other Maintainer"), + "compatibility": lambda p: p["compatibility"].update(requiredClientVersion="9.0.0"), + "policy": lambda p: (p.update(spec={"kind": "agent", "installPolicy": "system", "updatePolicy": "repository"}), + p.update(release=unknown()), p["governance"].update(availability="listing-only")), + } + for name, mutation in mutations.items(): + with self.subTest(name=name): + self.write_agent_pointer_case(mutate_sidecar=mutation) + self.assertTrue(any(issue.rule == "legacy-consistency" for issue in self.validate().issues)) + + def test_rejects_agent_pointer_entry_id_that_is_not_the_catalog_slug(self) -> None: + self.write_agent_pointer_case(mutate_entry=lambda p: p.update(id="00000000-0000-4000-8000-000000000000")) + self.assertTrue(any("entry.id" in issue.message for issue in self.validate().issues)) + + def test_agent_pointer_must_keep_both_immutable_source_and_governance_evidence(self) -> None: + cases = [ + (lambda p: p["source"].pop("ref"), None), + (lambda p: p["source"].update(ref="main"), + lambda p: (p["provenance"]["content"].update(ref="main"), p["governance"]["compliance"].update(reviewedRef="main"))), + (None, lambda p: p["governance"].update(license=unknown())), + (None, lambda p: p["governance"].pop("compliance")), + (None, lambda p: p["timestamps"].update(reviewedAt=unknown())), + (None, lambda p: p["governance"]["compliance"].update(reviewedRef="b" * 40)), + ] + for index, (entry_change, metadata_change) in enumerate(cases): + with self.subTest(index=index): + self.write_agent_pointer_case(entry_change, metadata_change) + self.assertTrue(any(issue.rule == "installable-evidence" for issue in self.validate().issues)) + + def test_rejects_invalid_raw_agent_entry_even_if_sidecar_repeats_it(self) -> None: + cases = [ + (lambda p: p.update(latestVersion="1.2.3-beta"), lambda p: p["release"].update(version="1.2.3-beta")), + (lambda p: p.update(latestVersion=123), lambda p: p["release"].update(version="123", versionScheme="opaque")), + (lambda p: p.update(latestVersion="١.٢.٣"), lambda p: p["release"].update(version="١.٢.٣", versionScheme="opaque")), + (lambda p: p.update(requiredClientVersion="1.2.3-beta"), lambda p: p["compatibility"].update(requiredClientVersion="1.2.3-beta")), + (lambda p: p.update(requiredClientVersion=None), None), + (lambda p: p.pop("updatePolicy"), None), + (lambda p: p.pop("installPolicy"), None), + (lambda p: p.update(installPolicy="system", updatePolicy="market"), None), + (lambda p: p["source"].update(repoBranch=123), None), + (lambda p: p["source"].update(path="C:/outside"), lambda p: p["provenance"]["content"].update(path="C:/outside")), + (lambda p: p["source"].update(path="C:\\outside"), None), + (lambda p: p["source"].update(path="../outside"), None), + (lambda p: p["source"].update(path=0), None), + (lambda p: p["source"].update(ref=None), None), + (lambda p: p["source"].update(unknown=True), None), + (lambda p: p.update(version="1.2.3"), None), + (lambda p: p.update(name=42), None), + (lambda p: p.update(i18n={"en-US": {"name": 42}}), None), + (lambda p: p["maintainer"].update(verified="true"), None), + ] + for index, (entry_change, metadata_change) in enumerate(cases): + with self.subTest(index=index): + self.write_agent_pointer_case(entry_change, metadata_change) + self.assertTrue(any(issue.rule == "agent-entry-schema" for issue in self.validate().issues)) + + def test_agent_pointer_default_policy_cannot_be_replaced_by_sidecar(self) -> None: + def default_policy(payload): + payload.pop("installPolicy") + payload.pop("updatePolicy") + + self.write_agent_pointer_case(default_policy) + self.assertFalse(self.validate().has_errors) + self.write_agent_pointer_case(default_policy, lambda p: p.update(spec={"kind": "agent"})) + self.assertFalse(self.validate().has_errors) + + def system_policy(payload): + payload["spec"].update(installPolicy="system", updatePolicy="repository") + payload["governance"]["availability"] = "listing-only" + payload["release"] = unknown() + + self.write_agent_pointer_case(default_policy, system_policy) + self.assertTrue(any(issue.rule == "legacy-consistency" for issue in self.validate().issues)) + + def test_accepts_explicit_system_agent_pointer_with_matching_listing_policy(self) -> None: + def system_policy(payload): + payload["spec"].update(installPolicy="system", updatePolicy="repository") + payload["governance"]["availability"] = "listing-only" + payload["release"] = unknown() + + self.write_agent_pointer_case(lambda p: p.update(installPolicy="system", updatePolicy="repository"), system_policy) + report = self.validate(require_complete=True) + self.assertFalse(report.has_errors, report.issues) + + def test_agent_pointer_sidecar_does_not_allow_provider_or_runtime_fields(self) -> None: + self.write_agent_pointer_case(mutate_sidecar=lambda p: p["identity"].update(catalogSourceId="market:official")) + self.assertTrue(any(issue.rule == "catalog-schema" for issue in self.validate().issues)) + + def test_agent_sidecar_rejects_missing_or_ambiguous_legacy_file(self) -> None: + entry, sidecar = self.write_agent_pointer_case() + entry.unlink() + report = self.validate() + self.assertTrue(any(issue.rule == "fixed-sidecar-path" and "agent.json or entry.json" in issue.message for issue in report.issues)) + self.write_agent_pointer_case() + self.write_json(entry.with_name("agent.json"), {"id": "example-agent"}) + report = self.validate() + self.assertTrue(any(issue.rule == "fixed-sidecar-path" and "exactly one" in issue.message for issue in report.issues)) + + def test_agent_pointer_requires_sidecar_under_complete_coverage(self) -> None: + _, sidecar = self.write_agent_pointer_case() + sidecar.unlink() + self.assertTrue(any(issue.rule == "sidecar-coverage" for issue in self.validate(require_complete=True).issues)) + def test_accepts_valid_listing_only_pointer(self) -> None: report = self.validate(require_complete=True) self.assertFalse(report.has_errors, report.issues) diff --git a/scripts/catalog/validate_catalog_metadata.py b/scripts/catalog/validate_catalog_metadata.py index 28ff0c7..9d97e46 100644 --- a/scripts/catalog/validate_catalog_metadata.py +++ b/scripts/catalog/validate_catalog_metadata.py @@ -22,11 +22,12 @@ from pathlib import Path from typing import Any, Iterable import yaml -from jsonschema import Draft7Validator, FormatChecker +from jsonschema import Draft7Validator, FormatChecker, ValidationError, validators REPO_ROOT = Path(__file__).resolve().parents[2] SCHEMA_PATH = REPO_ROOT / "schemas" / "catalog-metadata.v1.schema.json" +AGENT_ENTRY_SCHEMA_NAME = "market-agent-entry.client.schema.json" SIDECAR_NAME = "catalog-metadata.v1.json" EXPECTED_SCHEMA_REF = "../../schemas/catalog-metadata.v1.schema.json" FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL) @@ -34,6 +35,16 @@ FULL_GIT_REF_RE = re.compile(r"^[a-fA-F0-9]{40}(?:[a-fA-F0-9]{24})?$") CONTAINER_DIGEST_RE = re.compile(r"^sha256:[a-fA-F0-9]{64}$") +def _client_pattern(validator, pattern, instance, schema): + # The exported client patterns use ECMAScript ASCII \d; Python's default + # Unicode \d would admit version strings that the client rejects. + if validator.is_type(instance, "string") and re.search(pattern, instance, re.ASCII) is None: + yield ValidationError(f"{instance!r} does not match {pattern!r}") + + +AgentEntryValidator = validators.extend(Draft7Validator, {"pattern": _client_pattern}) + + @dataclass(frozen=True) class Issue: path: str @@ -146,23 +157,46 @@ def _legacy_i18n(raw: Any, *, short_key: str) -> tuple[dict[str, dict[str, Any]] return locales, default_locale if isinstance(default_locale, str) else None -def load_legacy(sidecar: Path, report: Report, root: Path) -> LegacyItem | None: +def load_legacy(sidecar: Path, report: Report, root: Path, agent_entry_validator) -> LegacyItem | None: parent = sidecar.parent root_kind = parent.parent.name if root_kind == "agents": - legacy_path = parent / "agent.json" + inline_path = parent / "agent.json" + pointer_path = parent / "entry.json" + if inline_path.is_file() == pointer_path.is_file(): + report.add( + _rel(sidecar, root), + "fixed-sidecar-path", + "agent sidecar must be next to exactly one legacy agent.json or entry.json", + ) + return None + is_pointer = pointer_path.is_file() + legacy_path = pointer_path if is_pointer else inline_path data = _read_json(legacy_path, report, root, "legacy-read") if data is None: return None + if is_pointer: + errors = list(agent_entry_validator.iter_errors(data)) + for error in errors: + report.add(_rel(legacy_path, root), "agent-entry-schema", f"{_json_path(error)}: {error.message}") + if errors: + return None + if is_pointer and data.get("id") != parent.name: + report.add(_rel(legacy_path, root), "legacy-consistency", "entry.id must equal the catalog directory slug") + # The pointer ID is a listing slug, not the upstream AgentFS UUID. + # Client indexing reads latestVersion for pointers and version for inline metadata. + version = data.get("latestVersion") if is_pointer else ( + str(data["version"]) if data.get("version") is not None else None + ) i18n, default_locale = _legacy_i18n(data.get("i18n"), short_key="shortDesc") return LegacyItem( kind="agent", item_id=parent.name, - source_type="agent", + source_type="pointer" if is_pointer else "agent", data=data, i18n=i18n, default_locale=default_locale, - version=str(data["version"]) if data.get("version") is not None else None, + version=version, category=data.get("category") if isinstance(data.get("category"), str) else None, tags=data.get("tags") if isinstance(data.get("tags"), list) else None, ) @@ -327,7 +361,16 @@ def _validate_content_consistency( } for sidecar_key, legacy_key in mapping.items(): legacy_value = source.get(legacy_key) - if legacy_value is not None: + if legacy.kind == "agent": + declared_value = content.get(sidecar_key) + # A sidecar must describe the exact pointer that the client installs, + # not add a different subdirectory or pin that entry.json never uses. + if sidecar_key in {"path", "ref"}: + declared_value = None if declared_value == "" else declared_value + legacy_value = None if legacy_value == "" else legacy_value + if declared_value != legacy_value: + report.add(rel, "legacy-consistency", f"provenance.content.{sidecar_key} differs from the Agent pointer") + elif legacy_value is not None: _compare( report, rel, @@ -535,6 +578,7 @@ def validate_sidecar( report: Report, root: Path, supported_locales: set[str], + agent_entry_validator, ) -> None: rel = _rel(sidecar_path, root) sidecar = _read_json(sidecar_path, report, root, "catalog-schema") @@ -545,7 +589,7 @@ def validate_sidecar( if any(issue.path == rel and issue.rule == "catalog-schema" for issue in report.issues): return - legacy = load_legacy(sidecar_path, report, root) + legacy = load_legacy(sidecar_path, report, root, agent_entry_validator) if legacy is None: return identity = sidecar["identity"] @@ -586,6 +630,16 @@ def validate_sidecar( else: _compare(report, rel, "release.version", release.get("version"), legacy.version) + if legacy.kind == "agent" and legacy.source_type == "pointer": + for field in ("installPolicy", "updatePolicy"): + # Ordinary pointers default to market/market in the client. A sidecar + # cannot turn an omitted pair into a system/repository listing. + _compare(report, rel, f"spec.{field}", spec.get(field, "market"), legacy.data.get(field, "market")) + _compare( + report, rel, "compatibility.requiredClientVersion", + sidecar["compatibility"].get("requiredClientVersion"), legacy.data.get("requiredClientVersion"), + ) + _validate_content_consistency(report, rel, sidecar, legacy) _validate_governance_consistency(report, rel, sidecar, legacy) _validate_collection(report, rel, sidecar, legacy, supported_locales) @@ -636,6 +690,13 @@ def validate_sidecar( ) return + if legacy.kind == "agent" and legacy.source_type == "pointer": + if not _content_is_immutable(legacy.data.get("source")): + report.add( + rel, "installable-evidence", + "installable Agent entry.source must itself declare an immutable ref or SHA-256 digest", + ) + if not _content_is_immutable(content): report.add(rel, "installable-evidence", "installable content must have an immutable ref or SHA-256 digest") if not isinstance(license_fact, dict) or license_fact.get("state") != "known": @@ -720,6 +781,15 @@ def validate_repository(root: Path = REPO_ROOT, *, require_complete: bool = Fals except Exception as exc: # jsonschema raises several SchemaError subclasses report.add(_rel(schema_path, root), "catalog-schema", f"invalid JSON Schema: {exc}") return report + agent_entry_schema = _read_json(root / "schemas" / AGENT_ENTRY_SCHEMA_NAME, report, root, "agent-entry-schema") + if agent_entry_schema is None: + return report + try: + AgentEntryValidator.check_schema(agent_entry_schema) + except Exception as exc: + report.add(f"schemas/{AGENT_ENTRY_SCHEMA_NAME}", "agent-entry-schema", f"invalid JSON Schema: {exc}") + return report + agent_entry_validator = AgentEntryValidator(agent_entry_schema, format_checker=FormatChecker()) validator = Draft7Validator(schema, format_checker=FormatChecker()) sidecars = _discover_sidecars(root) supported_locales = _load_supported_locales(root, report) @@ -732,7 +802,7 @@ def validate_repository(root: Path = REPO_ROOT, *, require_complete: bool = Fals f"{SIDECAR_NAME} is only allowed at agents// or skills//", ) continue - validate_sidecar(sidecar, validator, report, root, supported_locales) + validate_sidecar(sidecar, validator, report, root, supported_locales, agent_entry_validator) _validate_stats(root, report, sidecars) if require_complete: