diff --git a/dokimi/changelog.d/20261031_120000_napetrov_distribution-digest.md b/dokimi/changelog.d/20261031_120000_napetrov_distribution-digest.md new file mode 100644 index 000000000..9e2af52fb --- /dev/null +++ b/dokimi/changelog.d/20261031_120000_napetrov_distribution-digest.md @@ -0,0 +1,8 @@ + + +### Added + +- **`subject.distribution_digest` in the subject scorecard** — the `sha256:` digest a `.well-known/agent-skills/index.json` entry (agentskills PR #254) would carry for the subject: the bytes of `SKILL.md` when its directory holds nothing else (a directory ref or a `SKILL.md` file ref alike). A skill with resources is served as an archive, whose bytes a checkout cannot predict, so it gets `null` under either ref, and so does a ref that is not a local path. A client can match a published result to the skill it installed by this field. `ref_digest` is unchanged: a content digest of the tree, which no index carries. diff --git a/dokimi/dokimi/schemas/subject_scorecard.v2.json b/dokimi/dokimi/schemas/subject_scorecard.v2.json index 3c3d85899..af9b200be 100644 --- a/dokimi/dokimi/schemas/subject_scorecard.v2.json +++ b/dokimi/dokimi/schemas/subject_scorecard.v2.json @@ -45,6 +45,12 @@ "null" ] }, + "distribution_digest": { + "type": [ + "string", + "null" + ] + }, "members": { "type": "array" } diff --git a/dokimi/dokimi/subjects/scorecard.py b/dokimi/dokimi/subjects/scorecard.py index 4e0493541..b2772bf19 100644 --- a/dokimi/dokimi/subjects/scorecard.py +++ b/dokimi/dokimi/subjects/scorecard.py @@ -6,6 +6,7 @@ from __future__ import annotations import hashlib +import re from datetime import datetime, timezone from pathlib import Path from typing import Any @@ -76,11 +77,13 @@ def build_subject_scorecard( "kind": descriptor.subject.kind, "ref": descriptor.subject.ref, "ref_digest": _digest_ref(descriptor.subject.ref, base=Path.cwd()), + "distribution_digest": distribution_digest(descriptor.subject.ref, base=Path.cwd()), "members": [ { "kind": member.kind, "ref": member.ref, "ref_digest": _digest_ref(member.ref, base=Path.cwd()), + "distribution_digest": distribution_digest(member.ref, base=Path.cwd()), } for member in descriptor.subject.members ], @@ -138,6 +141,37 @@ def _digest_values(values: tuple[str, ...]) -> str: return "sha256:" + h.hexdigest() +def distribution_digest(ref: str | None, *, base: Path) -> str | None: + """The digest a ``.well-known/agent-skills`` index would publish for *ref*, or None. + + That index (agentskills PR #254) hashes the bytes it serves: one ``SKILL.md`` for a + ``skill-md`` entry, the archive for an ``archive`` entry. Only the first can be derived + from a checkout. A ``SKILL.md`` is hashed as is when its directory holds nothing else, + whether the ref names the file or the directory. A skill whose directory holds resources + is served as an archive, whose bytes depend on how it was packed, so it gets None rather + than a digest no index carries, under either spelling of its ref. So does a ref that is + not a local path. ``ref_digest`` stays a content digest of the tree. + """ + if not ref: + return None + if re.match(r"[A-Za-z][A-Za-z0-9+.-]+:", ref) and not ref.startswith("local:"): + return None + path = Path(ref.removeprefix("local:")) + if not path.is_absolute(): + path = base / path + if path.is_dir(): + skill_dir = path + elif path.name == "SKILL.md" and path.is_file(): + skill_dir = path.parent + else: + return None + skill = skill_dir / "SKILL.md" + # Any other entry makes it an archive; one stray file is enough, so stop at the second. + if [p.name for _, p in zip(range(2), skill_dir.iterdir(), strict=False)] != ["SKILL.md"] or not skill.is_file(): + return None + return "sha256:" + hashlib.sha256(skill.read_bytes()).hexdigest() + + def _digest_ref(ref: str | None, *, base: Path) -> str | None: if not ref: return None diff --git a/dokimi/tests/test_subjects_digest.py b/dokimi/tests/test_subjects_digest.py new file mode 100644 index 000000000..2892617e0 --- /dev/null +++ b/dokimi/tests/test_subjects_digest.py @@ -0,0 +1,85 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""subject.distribution_digest: the digest a .well-known/agent-skills index carries.""" + +from __future__ import annotations + +import hashlib +import sys +from pathlib import Path + +import pytest + +from dokimi.subjects.scorecard import distribution_digest + + +def test_distribution_digest_matches_a_well_known_skill_md_entry(tmp_path: Path): + # agentskills PR #254: a skill-md index entry carries sha256 of the SKILL.md bytes it serves. + skill = tmp_path / "skill" + skill.mkdir() + (skill / "SKILL.md").write_bytes(b"---\r\nname: s\r\n---\r\nbody\r\n") + expected = "sha256:" + hashlib.sha256(b"---\r\nname: s\r\n---\r\nbody\r\n").hexdigest() + + assert distribution_digest("skill", base=tmp_path) == expected + assert distribution_digest("local:skill/SKILL.md", base=tmp_path) == expected + + # With resources the skill is served as an archive, whose bytes a checkout cannot predict. + (skill / "ref.md").write_text("more", encoding="utf-8") + assert distribution_digest("skill", base=tmp_path) is None + assert distribution_digest("skill/SKILL.md", base=tmp_path) is None # the same skill, by its file ref + # A ref that is not a local path has no served bytes to hash. + assert distribution_digest("context7:/org/lib", base=tmp_path) is None + assert distribution_digest(None, base=tmp_path) is None + # Only SKILL.md is served as a skill-md entry; another file has no index digest. + (tmp_path / "notes.md").write_text("x", encoding="utf-8") + assert distribution_digest("notes.md", base=tmp_path) is None + + +@pytest.mark.skipif(sys.platform == "win32", reason="a colon is invalid in a Windows directory name") +def test_a_ref_with_a_scheme_gets_no_digest_even_when_a_directory_collides(tmp_path: Path): + colliding = tmp_path / "context7:" / "org" / "lib" + colliding.mkdir(parents=True) + (colliding / "SKILL.md").write_bytes(b"body") + + assert distribution_digest("context7:/org/lib", base=tmp_path) is None + + +def test_the_scorecard_carries_the_distribution_digest(tmp_path: Path, monkeypatch): + from dokimi.artifacts import validate_artifact + from dokimi.subjects import build_subject_scorecard, load_subject + + (tmp_path / "skill").mkdir() + (tmp_path / "skill" / "SKILL.md").write_bytes(b"body") + (tmp_path / "subject.toml").write_text( + '[subject]\nid = "s"\nkind = "skill"\nref = "skill"\n\n' + '[suite]\nproducts = ["p"]\nquestions = ["q.json"]\n', encoding="utf-8") + monkeypatch.chdir(tmp_path) + + scorecard = build_subject_scorecard(load_subject(tmp_path / "subject.toml"), awareness_runs=[], out_dir=tmp_path) + + assert scorecard["subject"]["distribution_digest"] == "sha256:" + hashlib.sha256(b"body").hexdigest() + validate_artifact("subject_scorecard", scorecard) + + +def test_the_scorecard_carries_a_distribution_digest_per_member(tmp_path: Path, monkeypatch): + from dokimi.subjects import build_subject_scorecard, load_subject + + (tmp_path / "standalone").mkdir() + (tmp_path / "standalone" / "SKILL.md").write_bytes(b"member body") + (tmp_path / "bundled").mkdir() + (tmp_path / "bundled" / "SKILL.md").write_bytes(b"bundled body") + (tmp_path / "bundled" / "ref.md").write_text("resource", encoding="utf-8") + (tmp_path / "subject.toml").write_text( + '[subject]\nid = "b"\nkind = "bundle"\n\n' + '[[subject.members]]\nkind = "skill"\nref = "standalone"\n\n' + '[[subject.members]]\nkind = "skill"\nref = "bundled/SKILL.md"\n\n' + '[[subject.members]]\nkind = "skill"\nref = "context7:/org/lib"\n\n' + '[suite]\nproducts = ["p"]\nquestions = ["q.json"]\n', encoding="utf-8") + monkeypatch.chdir(tmp_path) + + scorecard = build_subject_scorecard(load_subject(tmp_path / "subject.toml"), awareness_runs=[], out_dir=tmp_path) + + # The standalone member has a digest; the bundled one has resources, so neither its file nor its directory does. + digests = [member["distribution_digest"] for member in scorecard["subject"]["members"]] + assert digests == ["sha256:" + hashlib.sha256(b"member body").hexdigest(), None, None]