Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions src/agent_env/bundle/_fs.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""Filesystem plumbing for the bundle parser: a path spelled as the filesystem stores it, the user's
home, a regular file read without surprises, names compared without case, and paths rendered for
messages."""
home, a regular file read without surprises, a file's content hash, names compared without case, and paths rendered
for messages."""

from __future__ import annotations

import hashlib
import os
import stat
import sys
Expand Down Expand Up @@ -96,6 +97,14 @@ def read_regular(path: Path) -> bytes:
return data


def file_sha256(path: Path) -> str:
digest = hashlib.sha256()
with open(path, "rb") as file:
for chunk in iter(lambda: file.read(1 << 20), b""):
digest.update(chunk)
return "sha256:" + digest.hexdigest()


def fold(name: str) -> str:
"""The canonical caseless form: casefold output isn't always NFC, so normalize both sides."""
return unicodedata.normalize("NFC", unicodedata.normalize("NFC", name).casefold())
Expand Down
42 changes: 37 additions & 5 deletions src/agent_env/bundle/_import_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,28 @@

from __future__ import annotations

import hashlib
import json
import posixpath
import unicodedata
from dataclasses import dataclass
from datetime import datetime, timezone
from importlib.metadata import version as package_version
from pathlib import Path

from agent_env.bundle._fs import file_sha256
from agent_env.config import get_config
from agent_env.config.model import ENV_API_KEY, ENV_BASE_URL
from agent_env.entity.refs import EntityKind
from agent_env.store.image_store.oci_registry_credentials import registry_host_from_ref
from agent_env.utils.docker.build import DOCKER_PLUGINS
from agent_env.utils.docker.lock import IN_IMAGE, Locked
from agent_env.utils.docker.prune import Pruned

# The file an import records what it wrote in, and from where, for publishing the folder back.
# The file an import records what it wrote in, and from where, for registering the folder back.
PROVENANCE = "provenance.json"
# Bumped when what the file records changes.
PROVENANCE_SCHEMA = 2


@dataclass(frozen=True)
Expand Down Expand Up @@ -155,22 +161,48 @@ def size_text(size: int) -> str:
return f"{size} B" if size < 1_000 else f"{size / 1_000:.1f} KB" if size < 1_000_000 else f"{size / 1_000_000:.1f} MB"


def provenance(source: dict, imported: BundleImport) -> str:
"""What the folder was imported from and what it holds, by store id and version: no store's locations."""
def provenance(source: dict, imported: BundleImport, root: Path) -> str:
"""What the folder was imported from and what it holds, by store id and version, and each file written at ``root``,
so an edit can be told from what the store holds: no store's locations, only a fingerprint of the store's config."""
return json.dumps({
"schema": PROVENANCE_SCHEMA,
"agent_env": package_version("agentenv-framework"),
"imported_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"from": source,
"store": store_fingerprint(),
"tasks": [{"file": task.path, "id": task.id, "version": task.version} for task in imported.tasks],
"eval": {"file": imported.eval.path, "id": imported.eval.id, "version": imported.eval.version}
if imported.eval else None,
"written": [{"folder": entity.folder, "kind": entity.kind.value, "type": entity.type, "id": entity.id,
"version": entity.version} for entity in imported.written],
"images": [{"folder": image.folder, "key": image.key, "id": image.artifact, "version": image.version,
"dockerfile": image.pruned.dockerfile, "dockerfile_found": image.found}
for image in imported.images],
"store": [{"where": pin.where, "kind": pin.kind.value, "id": pin.id, "version": pin.version, "why": pin.why}
for pin in imported.pinned],
"pinned": [{"where": pin.where, "kind": pin.kind.value, "id": pin.id, "version": pin.version, "why": pin.why}
for pin in imported.pinned],
"files": written_files(root),
}, indent=2, ensure_ascii=False) + "\n"


def store_fingerprint() -> str:
"""A hash of the configured document store's section, which tells one store from another without naming where
either is."""
section = get_config().trace_section("document").value
text = json.dumps(section, sort_keys=True, separators=(",", ":"), default=str)
return "sha256:" + hashlib.sha256(text.encode()).hexdigest()


def written_files(root: Path) -> dict[str, str]:
"""Each file under ``root`` but its README and provenance, by its path in NFC, as its content hash, ``+x`` after it
when it's executable."""
files = {}
for path in sorted(root.rglob("*")):
relative = unicodedata.normalize("NFC", path.relative_to(root).as_posix())
if path.is_file() and relative not in ("README.md", PROVENANCE):
files[relative] = file_sha256(path) + ("+x" if path.stat().st_mode & 0o111 else "")
return files


def readme(description: str, imported: BundleImport) -> str:
"""The bundle's README: its description, where it came from, how to run and share it, what it holds, then the
import's report."""
Expand Down
2 changes: 1 addition & 1 deletion src/agent_env/bundle/importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,6 @@ def write(self, description: str, source: dict) -> BundleImport:
pinned=tuple(self.pinned), floating=tuple(self.floating), dropped=tuple(self.dropped),
needs=tuple(self.needs), undeclared=tuple(self.undeclared), duplicates=tuple(self.duplicates),
downloaded=downloaded)
self.files[PROVENANCE] = provenance(source, imported)
self.files["README.md"] = readme(description, imported)
try:
for folder, (name, toml) in self.tomls.items():
Expand All @@ -304,6 +303,7 @@ def write(self, description: str, source: dict) -> BundleImport:
for path, text in self.files.items():
(staging / path).parent.mkdir(parents=True, exist_ok=True)
(staging / path).write_text(text, encoding="utf-8")
(staging / PROVENANCE).write_text(provenance(source, imported, staging), encoding="utf-8")
except OSError as e:
raise BundleError([f"{show(self.dest.parent)}: can't write the import there: {os_reason(e)}"]) from None
check_bundle(resolve_bundle(parse_bundle(staging)), installed=installed_bundles)
Expand Down
11 changes: 2 additions & 9 deletions src/agent_env/bundle/ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from agent_env.store.local_state import holding_locks
from agent_env.task.store import TASKS_COLLECTION

from ._fs import file_sha256
from .authoring import build_context_files
from .parse import BundleKind
from .plan import Plan, Write, artifact_writer, env_folder_writer, env_writer, folder_files, unpinned_store_refs
Expand Down Expand Up @@ -127,7 +128,7 @@ def digest(self, write: Write, needs: Mapping[tuple[str, str], int]) -> Digest |
built = isinstance(write.source, BuiltImage)
listing = build_context_files if built else folder_files
for key, path in listing(self._plan.bundle_of(write.source.entry), write.source.entry).items():
inputs["files"][key] = _file_sha256(path) + (_mode(path) if built else "")
inputs["files"][key] = file_sha256(path) + (_mode(path) if built else "")
if (published := self._plan.published.get(write.id)) is not None:
inputs["published"] = published.pinned
if write.kind not in (BundleKind.TASK, BundleKind.EVAL):
Expand Down Expand Up @@ -308,13 +309,5 @@ def _mode(path: Path) -> str:
return "" if mode == _USUAL_MODE else f"+{mode:o}"


def _file_sha256(path: Path) -> str:
digest = hashlib.sha256()
with open(path, "rb") as file:
for chunk in iter(lambda: file.read(1 << 20), b""):
digest.update(chunk)
return "sha256:" + digest.hexdigest()


def _now() -> str:
return datetime.now(timezone.utc).isoformat()
33 changes: 32 additions & 1 deletion tst/unit/bundle/importer_entities_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
stored build context, downloading a context stored as one object once; and what a folder can't hold fails the import,
unless --include-scope user leaves it in the store, pinned."""

import hashlib
import json
import os
import tomllib
Expand All @@ -19,6 +20,7 @@
from agent_env.artifact.artifacts.skill import SkillArtifact
from agent_env.artifact.store import get_artifact_store
from agent_env.bundle import BundleError
from agent_env.bundle._import_report import store_fingerprint
from agent_env.bundle.importer import import_task
from agent_env.bundle.resolve import build_step
from agent_env.cli import cli
Expand Down Expand Up @@ -83,7 +85,7 @@ def test_with_the_user_scope_it_stays_in_the_store_pinned_and_the_report_and_rea
("artifact", "crm-image", 1, "envs/crm: image")]
readme = (imported.root / "README.md").read_text()
assert "## Still needs the store" in readme and "except what *Still needs the store* lists" in readme
assert json.loads((imported.root / "provenance.json").read_text())["store"][0]["id"] == "crm-image"
assert json.loads((imported.root / "provenance.json").read_text())["pinned"][0]["id"] == "crm-image"


def test_the_cli_says_up_front_that_the_folder_still_needs_the_store(home):
Expand Down Expand Up @@ -274,14 +276,43 @@ def test_provenance_names_each_written_entity_and_image_by_store_id_and_version_

text = (imported.root / "provenance.json").read_text()
provenance = json.loads(text)
assert provenance["schema"] == 2
assert provenance["from"] == {"task": "t", "version": 1}
assert provenance["tasks"] == [{"file": "tasks/t.json", "id": "t", "version": 1}] and provenance["eval"] is None
assert provenance["written"] == [{"folder": "envs/crm", "kind": "env", "type": "mcp_server", "id": "crm",
"version": 1}]
assert provenance["images"] == [{"folder": "envs/crm", "key": "image", "id": "crm-image", "version": 1,
"dockerfile": "Dockerfile", "dockerfile_found": False}]
assert provenance["store"] == store_fingerprint() and provenance["store"].startswith("sha256:")
assert "file://" not in text and str(home) not in text


def test_provenance_holds_each_file_the_import_wrote_as_written_so_an_edit_can_be_told(home):
"""Every file but the README and provenance itself, by its path in the folder, as its content hash, +x when
executable."""
MCPServerEnv.put(id="crm", docker_image_artifact=context_image(home, "crm-image", {
"Dockerfile": "FROM python:3.12-slim\nCOPY run.sh /\n", "run.sh": "#!/bin/sh\n"}), environment_name="crm")
_task([DEPLOY])

imported = import_task("t", home / "t")

files = json.loads((imported.root / "provenance.json").read_text())["files"]
on_disk = {path.relative_to(imported.root).as_posix() for path in imported.root.rglob("*") if path.is_file()}
assert set(files) == on_disk - {"README.md", "provenance.json"}
assert {"tasks/t.json", "envs/crm/env.toml", "envs/crm/Dockerfile"} <= set(files)
for path, digest in files.items():
executable = os.access(imported.root / path, os.X_OK)
expected = "sha256:" + hashlib.sha256((imported.root / path).read_bytes()).hexdigest()
assert digest == expected + ("+x" if executable else "")


def test_the_store_fingerprint_tells_one_configured_document_store_from_another(home, tmp_path, monkeypatch):
here = store_fingerprint()
monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "elsewhere"))

assert store_fingerprint() != here and store_fingerprint() == store_fingerprint()


def _saved_image(path, url, files):
"""A one-layer ``docker save`` tarball holding ``files``, stored at ``url``."""
path.write_bytes(saved_image([files], gz=True))
Expand Down
6 changes: 6 additions & 0 deletions tst/unit/bundle/importer_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,12 @@ def test_an_eval_imports_with_each_task_it_names_once_under_names_taken_from_the
assert imported.eval == ImportedEval("evals/regression.toml", "nightly/regression", 1)
plan = plan_bundle(resolve_bundle(parse_bundle(imported.root)))
assert [entry.entry.name for entry in plan.evals] == ["regression"]
provenance = json.loads((imported.root / "provenance.json").read_text())
assert provenance["eval"] == {"file": "evals/regression.toml", "id": "nightly/regression", "version": 1}
assert provenance["tasks"] == [{"file": "tasks/run.json", "id": "team/run", "version": 1},
{"file": "tasks/run-2.json", "id": "other/run", "version": 1},
{"file": "tasks/RUN-3.json", "id": "RUN", "version": 1},
{"file": "tasks/triage.json", "id": "triage", "version": 1}]


def test_an_eval_naming_a_task_the_store_doesnt_hold_is_refused_and_writes_nothing(store):
Expand Down
Loading