Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
bd52d56
feat(bundle): record what an import wrote, so registering the folder …
earakely-scale Oct 27, 2026
4ff3161
feat(bundle): agent-env bundle register writes a bundle into the conf…
earakely-scale Oct 27, 2026
707d700
fix(bundle): a registration that loses a race for a version fails rat…
earakely-scale Oct 27, 2026
cf01e94
fix(bundle): a write that fails leaves no pending ledger row
earakely-scale Oct 27, 2026
229f927
fix(bundle): pin each reference by its own field, resume an interrupt…
earakely-scale Oct 27, 2026
5f2382d
Merge branch 'feat/bundle-import-record-v2' into feat/bundle-register…
earakely-scale Oct 27, 2026
85adc0a
feat(bundle): register a folder bundle init wrote back into the store…
earakely-scale Oct 27, 2026
d469357
fix(bundle): don't register over a version someone else wrote since
earakely-scale Oct 27, 2026
c111063
Merge branch 'feat/bundle-register' into feat/bundle-register-imports
earakely-scale Oct 27, 2026
d89086f
fix(bundle): refuse an env:/secret: reference in a step's env vars, w…
earakely-scale Oct 27, 2026
0141fbe
test(bundle): join the remote URLs' fake credentials in at run time
earakely-scale Oct 27, 2026
8ee1f65
Merge branch 'feat/bundle-register' into feat/bundle-register-imports
earakely-scale Oct 27, 2026
f69989c
fix(bundle): check duplicate registry ids per store, and name the git…
earakely-scale Oct 27, 2026
7b57a7b
Merge branch 'feat/bundle-register' into feat/bundle-register-imports
earakely-scale Oct 27, 2026
908c8d9
fix(bundle): give back what an import left out, and keep each importe…
earakely-scale Oct 27, 2026
8e8eba1
refactor(bundle): let the ledger say how a registration writes, and r…
earakely-scale Oct 27, 2026
47be2b4
Merge branch 'feat/bundle-register' into feat/bundle-register-imports
earakely-scale Oct 27, 2026
b70a3be
fix(bundle): register a pulled image as imported, and keep a skill so…
earakely-scale Oct 27, 2026
8df98e6
test(bundle): register the echo-mcp example against a real registry, …
earakely-scale Oct 28, 2026
17b1699
Merge branch 'feat/bundle-register' into feat/bundle-register-imports
earakely-scale Oct 28, 2026
e6aced9
Merge remote-tracking branch 'origin/main' into feat/bundle-register
earakely-scale Oct 28, 2026
5bc8d33
fix(bundle): translate main's alias_uses, and keep the register integ…
earakely-scale Oct 28, 2026
49ee0db
Merge branch 'feat/bundle-register' into feat/bundle-register-imports
earakely-scale Oct 28, 2026
c8a21da
fix(bundle): read an imported folder as its writer does, and record a…
earakely-scale Oct 28, 2026
3529897
fix(bundle): restore a masked value in a reordered list from the item…
earakely-scale Oct 28, 2026
584b8dc
fix(bundle): give back a masked value to an edited item that holds it…
earakely-scale Oct 28, 2026
f28c646
fix(bundle): refuse a masked value only in a task the registration wr…
earakely-scale Oct 28, 2026
530bb76
refactor(bundle): drop the ownership check and --adopt from bundle re…
earakely-scale Oct 28, 2026
a33e0f0
Merge branch 'feat/bundle-register' into feat/bundle-register-imports
earakely-scale Oct 28, 2026
2232d4d
Merge remote-tracking branch 'origin/main' into feat/bundle-register-…
earakely-scale Oct 28, 2026
4aee364
fix(bundle): fingerprint where the document store is, and give an edi…
earakely-scale Oct 28, 2026
e71ec77
refactor(bundle): gather what an import record says of a plan into Im…
earakely-scale Oct 28, 2026
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
71 changes: 66 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,31 @@

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 typing import Any
from urllib.parse import urlsplit

from agent_env.bundle._fs import file_sha256
from agent_env.config import get_config
from agent_env.config.describe import is_secret_key
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 @@ -92,6 +101,8 @@ class Pulled:
folder: str
key: str
image: str
artifact: str # the stored image's id and version
version: int
why: str | None = None # why it's pulled though it has a build context: the context can't be written


Expand Down Expand Up @@ -155,22 +166,72 @@ 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],
Comment thread
greptile-apps[bot] marked this conversation as resolved.
"store": [{"where": pin.where, "kind": pin.kind.value, "id": pin.id, "version": pin.version, "why": pin.why}
for pin in imported.pinned],
"pulled": [{"folder": pull.folder, "key": pull.key, "id": pull.artifact, "version": pull.version}
for pull in imported.pulled],
"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 where the configured document store is: its section's ``impl``, and the strings its ``config`` holds,
``env:`` and ``secret:`` references resolved, a URL as its scheme, host and path alone, and nothing a secret-named
key holds. Credentials and tuning options don't tell one store from another, so neither changes it, and the hash
names neither where the store is nor how to reach it."""
config = get_config()
section = config.trace_section("document").value
located = {"impl": str(section.get("impl")), "config": _location(config.resolve_reference(section.get("config", {})))}
text = json.dumps(located, sort_keys=True, separators=(",", ":"))
return "sha256:" + hashlib.sha256(text.encode()).hexdigest()


def _location(value: Any) -> Any:
"""What in ``value`` says where a store is: each string, a URL as its scheme, host and path, through dicts but the
items of secret-named keys, and lists; None for anything else."""
if isinstance(value, dict):
return {key: located for key, item in value.items()
if not is_secret_key(str(key)) and (located := _location(item)) is not None}
if isinstance(value, list):
return [located for item in value if (located := _location(item)) is not None]
if isinstance(value, str):
parts = urlsplit(value)
return f"{parts.scheme}://{parts.netloc.rpartition('@')[2]}{parts.path}" if parts.scheme and parts.netloc else value
return None


def written_files(root: Path) -> dict[str, str]:
"""Each file under ``root`` but its README and provenance, by its path in NFC, as its ``file_digest``."""
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_digest(path)
return files


def file_digest(path: Path) -> str:
"""``path``'s content hash, ``+x`` after it when it's executable, as an import records each file it wrote."""
return file_sha256(path) + ("+x" if path.stat().st_mode & 0o111 else "")


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
Loading
Loading