From 5fc37b717bdf85e3a360807f9cfcab6ebf72b50a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 21:23:37 +0000 Subject: [PATCH 1/3] Port Stele promotion to the current ledger API; install promoted skills Fixes #1: EvecorStelePort targeted a Stele LedgerStore API removed in the ledger redesign (commit/DuplicateArtifactError/duplicate_policy), so the MCP server failed at import. It now records the candidate with create_pending(parser=agentsync-kanon, ...) and seal(), against a LedgerStore bound to a BlobStore evidence archive. SEALED is reported as PromotionState.COMMITTED. A retry for the same run_id reuses its record (sealed + same content -> committed; pending -> re-sealed; otherwise failed) instead of recording a second artifact. The stele extra is now pinned to a Deep-Sixed/Stele commit, and a missing/incompatible stele surfaces its real ImportError. Fixes #2: promotion never reached the approved catalog. After Stele seals, Kanon installs the candidate at approved//SKILL.md (atomic write; a different existing skill is rejected before Stele is called), reloads the Skill Server, and records redeemed_skill_id on the obligation. The Enforcer shares the server's SkillServer index and looks up a task's redeemed skill by id, so re-enforcing the task is covered instead of minting a new obligation. Storage scripts, env example and README cover the new storage/stele/archive directory. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VnaMtsgmbeB8QWCrqdpSWc --- .gitignore | 1 + README.md | 44 +++-- ops/agentsync.env.example | 1 + pyproject.toml | 3 +- scripts/backup-storage.sh | 5 +- scripts/bootstrap-storage.sh | 3 +- scripts/restore-storage.sh | 1 + src/agentsync/enforcer/enforcer.py | 20 +- src/agentsync/enforcer/models.py | 1 + src/agentsync/enforcer/store.py | 15 ++ src/agentsync/kanon/evecor_stele_port.py | 221 ++++++++++++++--------- src/agentsync/kanon/promotion_adapter.py | 41 ++++- src/agentsync/mcp/server.py | 19 +- src/agentsync/skills/registry.py | 43 +++++ tests/test_evecor_stele_port.py | 127 ++++++++++--- tests/test_local_service_smoke.py | 13 +- tests/test_mcp_service_smoke.py | 30 ++- tests/test_operational_hardening.py | 8 + tests/test_promotion_catalog.py | 206 +++++++++++++++++++++ 19 files changed, 650 insertions(+), 152 deletions(-) create mode 100644 tests/test_promotion_catalog.py diff --git a/.gitignore b/.gitignore index 7db452a..51b52a9 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ storage/stele/*.db storage/stele/*.db-shm storage/stele/*.db-wal storage/stele/artifacts/* +storage/stele/archive/ !storage/stele/.gitkeep !storage/stele/artifacts/.gitkeep storage/backups/ diff --git a/README.md b/README.md index 42dd754..a446ba0 100644 --- a/README.md +++ b/README.md @@ -7,36 +7,43 @@ missing, and promotes validated skills back into the shared skill catalog. One MCP service, registered once with ContextForge. > **Repo status:** this is the reference implementation for a private -> deployment. It is **not yet clone-and-run** for outside operators — the -> promotion substrate (`stele`) resolves from a local path and the MCP server -> imports it at startup. See [Stele substrate](#stele-substrate) and +> deployment. The MCP server imports the promotion substrate (`stele`) at +> startup; see [Stele substrate](#stele-substrate) and > [Local deployment](#local-deployment-deep-sixed) before attempting to run it. ## Stele substrate -The promotion step commits validated `SKILL.md` artifacts to a **Stele -ledger** — the real EVECOR Stele `LedgerStore`, driven **in-process as a -library dependency**, not a vendored copy and not a separate service. +The promotion step records validated `SKILL.md` artifacts in a **Stele +ledger** — Stele's `LedgerStore` bound to its evidence archive (`BlobStore`), +driven **in-process as a library dependency**, not a vendored copy and not a +separate service. Once sealed, the skill is installed into the approved +catalog (`storage/skills/approved//SKILL.md`) and the Skill Server +index is reloaded, so the next lookup for the task finds it. - `EvecorStelePort` (`src/agentsync/kanon/evecor_stele_port.py`) implements the `StelePromotionPort` seam against `stele.ledger.store.LedgerStore`. - It bypasses Stele's `ledger_transaction(store, SandboxResult)` write path — a promoted `SKILL.md` is a pre-trusted blob with no sandbox run — and calls - the store primitives directly: `create_pending(...)` then `commit(...)`. -- The `storage/stele/ledger.db` schema (`artifact_records`) and the artifact - files under `storage/stele/artifacts/` are owned by Stele, not by AgentSync. + the store primitives directly: `create_pending(parser=agentsync-kanon, ...)` + then `seal(...)`. A sealed record is reported as promotion state `committed`. +- The obligation's `run_id` is the Stele run id: one ledger record per + obligation, found with `store.get_by_run_id(run_id)`. +- The `storage/stele/ledger.db` schema (`artifact_records`), the evidence + archive under `storage/stele/archive/`, and the artifact files under + `storage/stele/artifacts/` are owned by Stele, not by AgentSync. - Stele is an **optional dependency** (`[stele]` extra). Tests run without it via `FakeStelePromotionPort`; the live MCP server requires it (it imports `stele.ledger.store` at module load and fails loud if absent). -Stele is currently sourced from a local path (`pyproject.toml`): +Stele is pinned to the commit whose ledger API the port targets +(`pyproject.toml`): ```toml -stele = ["stele @ file:///mnt/jarvis-data/projects/Stele"] +stele = ["stele @ git+https://github.com/Deep-Sixed/Stele@10bbf3f24a4cd4b15fc501c57ce58bb2fcf7b54a"] ``` -Outside operators must repoint this at their own Stele checkout, index, or -wheel before `--extra stele` or the MCP server will work. +Moving the pin means re-running `tests/test_evecor_stele_port.py` against the +new Stele. ## Setup @@ -99,8 +106,9 @@ systemctl --user status agentsync-mcp.service |------|-------|---------| | `storage/obligations/obligations.jsonl` | AgentSync | Authoritative obligation lifecycle log | | `storage/obligations/pre_obligations.jsonl` | AgentSync | Non-authoritative rule evaluation records | -| `storage/skills/approved/` | AgentSync | Approved SKILL.md registry | +| `storage/skills/approved/` | AgentSync | Approved SKILL.md registry (Kanon installs promoted skills here) | | `storage/stele/ledger.db` | Stele | Stele `LedgerStore` artifact ledger (`artifact_records`) | +| `storage/stele/archive/` | Stele | Stele evidence archive (`BlobStore`) the ledger points into | | `storage/stele/artifacts/` | Stele | Committed SKILL.md artifact files | All runtime paths overridable via `AGENTSYNC_*` env vars (see @@ -118,7 +126,8 @@ git-ignored — the tree ships empty dirs (`.gitkeep`) plus the `rules.yaml` / ./scripts/restore-storage.sh storage/backups/agentsync-YYYYMMDD-HHMMSS.tar.gz ``` -Backups include obligations, the Stele ledger, artifacts, and approved skills. +Backups include obligations, the Stele ledger and its evidence archive, +artifacts, and approved skills. ## ContextForge registration @@ -161,8 +170,9 @@ Concrete paths for the reference deployment on JARVIS: # project root cd /mnt/jarvis-data/projects/AgentSync -# Stele source (pyproject [stele] extra) -# stele @ file:///mnt/jarvis-data/projects/Stele +# Stele source: the [stele] extra pins a Deep-Sixed/Stele commit; to run +# against the local checkout instead: +# uv pip install -e /mnt/jarvis-data/projects/Stele # mint the ContextForge agent bearer ROUTERCORE_MCP_BEARER_TOKEN="$(/mnt/jarvis-data/projects/EVECOR/bin/evecor-routercore-mcp-bearer)" diff --git a/ops/agentsync.env.example b/ops/agentsync.env.example index fa299d0..6d7c75f 100644 --- a/ops/agentsync.env.example +++ b/ops/agentsync.env.example @@ -16,6 +16,7 @@ AGENTSYNC_PRE_OBLIGATIONS_PATH=/mnt/jarvis-data/projects/AgentSync/storage/oblig AGENTSYNC_APPROVED_ROOT=/mnt/jarvis-data/projects/AgentSync/storage/skills/approved AGENTSYNC_OBLIGATIONS_PATH=/mnt/jarvis-data/projects/AgentSync/storage/obligations/obligations.jsonl AGENTSYNC_STELE_DB_PATH=/mnt/jarvis-data/projects/AgentSync/storage/stele/ledger.db +AGENTSYNC_STELE_ARCHIVE_ROOT=/mnt/jarvis-data/projects/AgentSync/storage/stele/archive AGENTSYNC_ARTIFACTS_BASE=/mnt/jarvis-data/projects/AgentSync/storage/stele/artifacts # ContextForge agent auth (for smoke tests / MCP clients — not read by AgentSync server) diff --git a/pyproject.toml b/pyproject.toml index 3c90c76..b99b3e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,8 @@ dependencies = [ [project.optional-dependencies] mcp = ["mcp[cli]>=1.0"] # FastMCP transport — required to run agentsync.mcp.server dev = ["pytest>=8.0", "mcp[cli]>=1.0", "anyio>=4.0"] -stele = ["stele @ file:///mnt/jarvis-data/projects/Stele"] +# Pinned: EvecorStelePort targets this ledger API (create_pending(parser=...) -> seal). +stele = ["stele @ git+https://github.com/Deep-Sixed/Stele@10bbf3f24a4cd4b15fc501c57ce58bb2fcf7b54a"] [build-system] requires = ["hatchling"] diff --git a/scripts/backup-storage.sh b/scripts/backup-storage.sh index eb08b60..e49cf21 100755 --- a/scripts/backup-storage.sh +++ b/scripts/backup-storage.sh @@ -1,5 +1,6 @@ #!/usr/bin/env bash -# Backup AgentSync mutable state: obligations, Stele ledger, artifacts. +# Backup AgentSync mutable state: obligations, Stele ledger + evidence archive, +# artifacts, approved skills. # # Usage: # ./scripts/backup-storage.sh [output-dir] @@ -30,6 +31,8 @@ copy_if_exists() { copy_if_exists "$STORAGE/obligations" "$TMP/obligations" copy_if_exists "$STORAGE/stele/ledger.db" "$TMP/stele/ledger.db" copy_if_exists "$STORAGE/stele/artifacts" "$TMP/stele/artifacts" +# The ledger's records point into the archive; back them up together. +copy_if_exists "$STORAGE/stele/archive" "$TMP/stele/archive" copy_if_exists "$STORAGE/skills/approved" "$TMP/skills/approved" tar -czf "$ARCHIVE" -C "$TMP" . diff --git a/scripts/bootstrap-storage.sh b/scripts/bootstrap-storage.sh index f9b89ce..ee450ed 100755 --- a/scripts/bootstrap-storage.sh +++ b/scripts/bootstrap-storage.sh @@ -8,6 +8,7 @@ STORAGE="${AGENTSYNC_STORAGE:-$ROOT/storage}" mkdir -p \ "$STORAGE/obligations" \ "$STORAGE/skills/approved" \ - "$STORAGE/stele/artifacts" + "$STORAGE/stele/artifacts" \ + "$STORAGE/stele/archive" exit 0 diff --git a/scripts/restore-storage.sh b/scripts/restore-storage.sh index 4cbc9dc..7901087 100755 --- a/scripts/restore-storage.sh +++ b/scripts/restore-storage.sh @@ -58,6 +58,7 @@ if [[ -d "$TMP/stele" ]]; then mkdir -p "$STORAGE/stele" [[ -f "$TMP/stele/ledger.db" ]] && cp -a "$TMP/stele/ledger.db" "$STORAGE/stele/ledger.db" && echo "restored: $STORAGE/stele/ledger.db" [[ -d "$TMP/stele/artifacts" ]] && rm -rf "$STORAGE/stele/artifacts" && cp -a "$TMP/stele/artifacts" "$STORAGE/stele/artifacts" && echo "restored: $STORAGE/stele/artifacts" + [[ -d "$TMP/stele/archive" ]] && rm -rf "$STORAGE/stele/archive" && cp -a "$TMP/stele/archive" "$STORAGE/stele/archive" && echo "restored: $STORAGE/stele/archive" fi restore_tree "skills/approved" diff --git a/src/agentsync/enforcer/enforcer.py b/src/agentsync/enforcer/enforcer.py index 8b6c65d..9bca912 100644 --- a/src/agentsync/enforcer/enforcer.py +++ b/src/agentsync/enforcer/enforcer.py @@ -22,7 +22,7 @@ from .lookup import InProcessSkillLookup, SkillLookup from .models import EnforcementResult, ObligationStatus, SkillObligation -from .store import append_obligation, find_open_for_task, get_obligation +from .store import append_obligation, find_open_for_task, find_redeemed_for_task, get_obligation def _now() -> str: @@ -79,12 +79,16 @@ def enforce(self, evaluation: RuleEvaluation, task_description: str = "") -> Enf # Confirm the gap. The Skill Server's matcher is conservative, so a # 'missing' here is trustworthy. Feed it the real task text — without - # the description the matcher can't find coverage. + # the description the matcher can't find coverage. If this task already + # redeemed an obligation, the skill it produced is looked up by id: the + # task is covered while that skill is still approved. query_text = (task_description or "").strip() or task_id + redeemed = find_redeemed_for_task(task_id, self._path) result = self._lookup.find_matching_skill(SkillQuery( text=query_text, skill_family=evaluation.skill_family, capability_family=evaluation.capability_family, + canonical_id=redeemed.redeemed_skill_id if redeemed else None, )) if result.status == "found": @@ -134,18 +138,22 @@ def submit_candidate(self, token: str, candidate_path: str) -> SkillObligation: }) return append_obligation(updated, self._path) - def redeem(self, token: str, artifact_hash: str) -> SkillObligation: + def redeem( + self, token: str, artifact_hash: str, skill_id: str | None = None, + ) -> SkillObligation: """Kanon promoted the candidate through Stele. SUBMITTED -> REDEEMED. - artifact_hash is Stele's committed ArtifactRecord.artifact_hash — the - proof that promotion actually happened through the substrate. Closure - is unblocked here. + artifact_hash is Stele's sealed ArtifactRecord.artifact_hash — the + proof that promotion actually happened through the substrate. skill_id + is the approved canonical_id Kanon installed, if any. Closure is + unblocked here. """ obl = self._require_status(token, {ObligationStatus.SUBMITTED}) updated = obl.model_copy(update={ "status": ObligationStatus.REDEEMED, "closure_blocked": False, "redeemed_artifact_hash": artifact_hash, + "redeemed_skill_id": skill_id, "updated_at": _now(), "note": "redeemed after Stele commit", }) diff --git a/src/agentsync/enforcer/models.py b/src/agentsync/enforcer/models.py index 44dd575..f4c192f 100644 --- a/src/agentsync/enforcer/models.py +++ b/src/agentsync/enforcer/models.py @@ -49,6 +49,7 @@ class SkillObligation(BaseModel): updated_at: str candidate_path: str | None = None # set on SUBMITTED redeemed_artifact_hash: str | None = None # set on REDEEMED (from Stele) + redeemed_skill_id: str | None = None # set on REDEEMED: approved canonical_id note: str | None = None diff --git a/src/agentsync/enforcer/store.py b/src/agentsync/enforcer/store.py index ac620e0..637318b 100644 --- a/src/agentsync/enforcer/store.py +++ b/src/agentsync/enforcer/store.py @@ -64,3 +64,18 @@ def find_open_for_task(task_id: str, path: Path | None = None) -> SkillObligatio if obl.task_id == task_id and obl.status.value in ("open", "submitted"): return obl return None + + +def find_redeemed_for_task(task_id: str, path: Path | None = None) -> SkillObligation | None: + """Return the most recently redeemed obligation for a task, if any. + + A redeemed obligation names the approved skill it produced; the Enforcer + uses it so a task whose skill was already authored is not re-obligated. + """ + latest: SkillObligation | None = None + for obl in current_state(path).values(): + if obl.task_id != task_id or obl.status.value != "redeemed": + continue + if latest is None or obl.updated_at > latest.updated_at: + latest = obl + return latest diff --git a/src/agentsync/kanon/evecor_stele_port.py b/src/agentsync/kanon/evecor_stele_port.py index f7c5abe..b830da4 100644 --- a/src/agentsync/kanon/evecor_stele_port.py +++ b/src/agentsync/kanon/evecor_stele_port.py @@ -1,32 +1,41 @@ """EvecorStelePort — real Stele substrate implementation. -Implements StelePromotionPort against EVECOR's Stele ledger -(stele.ledger.store.LedgerStore). +Implements StelePromotionPort against the Stele ledger +(stele.ledger.store.LedgerStore, bound to a stele.archive.store.BlobStore). Shim rationale -------------- Stele's canonical write path is ledger_transaction(store, SandboxResult), -designed for untrusted parser output from a bubblewrap sandbox run. -A validated SKILL.md has no sandbox — it is a pre-trusted text blob that -Kanon already validated at PROMOTE level. So this port bypasses -ledger_transaction() and calls the store primitives directly: +designed for untrusted parser output from a sandbox run. A validated +SKILL.md has no sandbox — it is a pre-trusted text blob that Kanon already +validated at PROMOTE level. So this port bypasses ledger_transaction() and +calls the store primitives directly: - store.create_pending(run_id, artifact_dir, artifact_paths, ...) - store.commit(record_id) + store.create_pending(run_id=, artifact_dir=, artifact_paths=, + parser=KANON_PRODUCER, parser_config={...}) + store.seal(record_id) # archives + verifies the bundle -This is honest: the dishonesty in a dummy SandboxResult (exit_code=0, -wall_time=0, empty stdout/stderr) is replaced by the honest absence of a -SandboxResult entirely. The ledger gets a real artifact_dir with a real -SKILL.md file, a real sha256 manifest, and the correct run_id. +There is no input Snapshot (Kanon parsed no input document), so the record +carries no source_hash. The "parser" identity names the producer honestly: +Kanon at this AgentSync version, with the validation it applied as config. + +State mapping +------------- +Stele SEALED is reported as PromotionState.COMMITTED — the bundle is stored +in the evidence archive and verified, which is what "committed" promised. run_id threading ---------------- run_id is the Stele join key minted by the Enforcer when the obligation was created (SkillObligation.run_id). PromotionAdapter passes it in from -obl.run_id. This port uses it as the LedgerStore.run_id so the resulting -ArtifactRecord joins back to the obligation via: +obl.run_id. Stele allows exactly one record per run_id, so the record +joins back to the obligation via: + + store.get_by_run_id(obl.run_id) -> ArtifactRecord - store.find_by_run_id(obl.run_id) → [ArtifactRecord] +A retry with the same run_id reuses that record: SEALED with identical +content is returned as COMMITTED (idempotent), PENDING with identical content +is re-sealed, anything else is FAILED. Requires the `stele` optional dependency: pip install -e ".[stele]" @@ -36,150 +45,194 @@ import hashlib import logging import uuid as _uuid_mod +from importlib import metadata from pathlib import Path from typing import TYPE_CHECKING from agentsync.kanon.models import PromotionArtifact, PromotionState, SkillCandidate if TYPE_CHECKING: + from stele.ledger.models import ArtifactRecord from stele.ledger.store import LedgerStore logger = logging.getLogger(__name__) try: - from stele.ledger.store import DuplicateArtifactError, LedgerStore as _LedgerStore - from stele.ledger.models import ArtifactState - _STELE_AVAILABLE = True -except ImportError: - _STELE_AVAILABLE = False + from stele.ledger.models import ArtifactState, ParserIdentity + from stele.ledger.store import DuplicateRunError + _STELE_IMPORT_ERROR: ImportError | None = None +except ImportError as exc: # surfaced with its real cause at instantiation + _STELE_IMPORT_ERROR = exc + +KANON_PRODUCER_NAME = "agentsync-kanon" + + +def _agentsync_version() -> str: + try: + return metadata.version("agentsync") + except metadata.PackageNotFoundError: + return "unknown" def _sha256(content: str) -> str: - return hashlib.sha256(content.encode()).hexdigest() + return hashlib.sha256(content.encode("utf-8")).hexdigest() class EvecorStelePort: - """Real StelePromotionPort backed by EVECOR's Stele LedgerStore. + """Real StelePromotionPort backed by a Stele LedgerStore. Args: - store: A LedgerStore instance opened against the target DB. + store: A LedgerStore opened against the target DB and archive. artifact_base: Base directory for promotion artifacts. Each call gets its own subdir: artifact_base / run_id / The SKILL.md is written there before create_pending(). - Raises ImportError at instantiation if the `stele` package is not installed. + Raises ImportError at instantiation if the `stele` package is missing or + does not provide the ledger API this port targets. """ def __init__(self, store: LedgerStore, artifact_base: Path) -> None: - if not _STELE_AVAILABLE: + if _STELE_IMPORT_ERROR is not None: raise ImportError( - "EvecorStelePort requires the 'stele' package. " - "Install with: pip install -e '.[stele]'" - ) + "EvecorStelePort requires a compatible 'stele' package " + f"({_STELE_IMPORT_ERROR}). Install with: pip install -e '.[stele]'" + ) from _STELE_IMPORT_ERROR self._store = store self._artifact_base = Path(artifact_base) + self._producer = ParserIdentity( + name=KANON_PRODUCER_NAME, version=_agentsync_version(), + ) def commit_artifact( self, candidate: SkillCandidate, run_id: str, ) -> PromotionArtifact: - """Write candidate to disk, ledger PENDING → COMMITTED, return receipt. + """Write candidate to disk, ledger PENDING → SEALED, return receipt. Never raises on FAILED outcomes — failure is encoded in PromotionArtifact.state per the StelePromotionPort contract. Steps: - 1. Write candidate.content to artifact_base/run_id/ - 2. store.create_pending(run_id, artifact_dir, [skill_path], ...) - duplicate_policy="ignore" — idempotent for identical content - 3. store.commit(record_id) — re-verifies file exists before COMMITTED - 4. Return PromotionArtifact(state=COMMITTED) + 1. Reuse an existing record for run_id if one exists (retry path). + 2. Write candidate.content to artifact_base/run_id/ + 3. store.create_pending(...) with the Kanon producer identity + 4. store.seal(record_id) — archives and verifies the bundle + 5. Return PromotionArtifact(state=COMMITTED) - On any store error: mark pending record FAILED, return state=FAILED. + On a sealing error: mark the pending record FAILED, return state=FAILED. """ # Validate run_id — obligations always mint UUIDs; catching bad callers early # prevents a ledger record with an un-joinable key. try: _uuid_mod.UUID(run_id) - except (ValueError, AttributeError): + except (ValueError, AttributeError, TypeError): raise ValueError( f"run_id must be a valid UUID string; got {run_id!r}" ) - source_hash = _sha256(candidate.content) + content_hash = _sha256(candidate.content) - # Step 1: write artifact to disk - artifact_dir = self._artifact_base / run_id - artifact_dir.mkdir(parents=True, exist_ok=True) - skill_path = artifact_dir / candidate.file_name - skill_path.write_text(candidate.content, encoding="utf-8") + existing = self._store.get_by_run_id(run_id) + if existing is not None: + return self._resume(existing, candidate, content_hash) + + skill_path = self._write(candidate, run_id) - # Step 2: create pending ledger record - record = None try: record = self._store.create_pending( run_id=run_id, - artifact_dir=artifact_dir, + artifact_dir=skill_path.parent, artifact_paths=[skill_path], - source_path=str(skill_path), - source_hash=source_hash, - duplicate_policy="ignore", + parser=self._producer, + parser_config={ + "validation_level": "promote", + "dir_name": candidate.dir_name, + "file_name": candidate.file_name, + }, ) + except DuplicateRunError: + # Lost a race with a concurrent promotion of the same run. + existing = self._store.get_by_run_id(run_id) + if existing is None: + raise + return self._resume(existing, candidate, content_hash) except Exception as exc: logger.error( "evecor_stele_port: create_pending failed run_id=%s: %r", run_id, exc, ) - return PromotionArtifact( - artifact_hash=source_hash, - record_id="", - run_id=run_id, - state=PromotionState.FAILED, - ) + return self._receipt(None, run_id, PromotionState.FAILED, content_hash) - # Duplicate guard: duplicate_policy="ignore" may return an existing - # COMMITTED record (same artifact_hash, different run_id). - # Skip the commit step — the artifact is already in the ledger. - if record.state is ArtifactState.COMMITTED: - logger.info( - "evecor_stele_port: duplicate content already committed " - "record_id=%s run_id=%s", - record.record_id, run_id, - ) - return PromotionArtifact( - artifact_hash=record.artifact_hash, - record_id=record.record_id, - run_id=run_id, - state=PromotionState.COMMITTED, - ) + return self._seal(record) - # Step 3: commit pending → committed + # --- helpers ------------------------------------------------------------- + + def _write(self, candidate: SkillCandidate, run_id: str) -> Path: + artifact_dir = self._artifact_base / run_id + artifact_dir.mkdir(parents=True, exist_ok=True) + skill_path = artifact_dir / candidate.file_name + skill_path.write_text(candidate.content, encoding="utf-8") + return skill_path + + def _seal(self, record: ArtifactRecord) -> PromotionArtifact: try: - record = self._store.commit(record.record_id) + record = self._store.seal(record.record_id) except Exception as exc: logger.error( - "evecor_stele_port: commit failed record_id=%s run_id=%s: %r", - record.record_id, run_id, exc, + "evecor_stele_port: seal failed record_id=%s run_id=%s: %r", + record.record_id, record.run_id, exc, ) try: self._store.fail(record.record_id, error=repr(exc)) except Exception: pass - return PromotionArtifact( - artifact_hash=record.artifact_hash, - record_id=record.record_id, - run_id=run_id, - state=PromotionState.FAILED, - ) + return self._receipt(record, record.run_id, PromotionState.FAILED) logger.info( - "evecor_stele_port: committed run_id=%s record_id=%s hash=%s…", - run_id, record.record_id, record.artifact_hash[:12], + "evecor_stele_port: sealed run_id=%s record_id=%s hash=%s…", + record.run_id, record.record_id, record.artifact_hash[:12], ) + return self._receipt(record, record.run_id, PromotionState.COMMITTED) + + def _resume( + self, + record: ArtifactRecord, + candidate: SkillCandidate, + content_hash: str, + ) -> PromotionArtifact: + """Handle a second promotion attempt for a run_id the ledger already has.""" + same_content = record.artifact_manifest == {candidate.file_name: content_hash} + + if same_content and record.state is ArtifactState.SEALED: + logger.info( + "evecor_stele_port: run_id=%s already sealed with this content " + "(record_id=%s); returning it", record.run_id, record.record_id, + ) + return self._receipt(record, record.run_id, PromotionState.COMMITTED) + + if same_content and record.state is ArtifactState.PENDING: + # An earlier attempt stopped between create_pending and seal. + self._write(candidate, record.run_id) + return self._seal(record) + + logger.error( + "evecor_stele_port: run_id=%s already has record_id=%s (state=%s, " + "same_content=%s); refusing to record a second artifact for this run", + record.run_id, record.record_id, record.state.value, same_content, + ) + return self._receipt(record, record.run_id, PromotionState.FAILED) + + @staticmethod + def _receipt( + record: ArtifactRecord | None, + run_id: str, + state: PromotionState, + fallback_hash: str = "", + ) -> PromotionArtifact: return PromotionArtifact( - artifact_hash=record.artifact_hash, - record_id=record.record_id, + artifact_hash=record.artifact_hash if record is not None else fallback_hash, + record_id=record.record_id if record is not None else "", run_id=run_id, - state=PromotionState.COMMITTED, + state=state, ) diff --git a/src/agentsync/kanon/promotion_adapter.py b/src/agentsync/kanon/promotion_adapter.py index 53519c6..74c490f 100644 --- a/src/agentsync/kanon/promotion_adapter.py +++ b/src/agentsync/kanon/promotion_adapter.py @@ -14,14 +14,20 @@ ↓ StelePromotionPort.commit_artifact() ↓ state=COMMITTED - Enforcer.redeem(token, artifact_hash) + install into approved//SKILL.md (when approved_root is set) + ↓ on_installed() (e.g. SkillServer.reload) + Enforcer.redeem(token, artifact_hash, skill_id) ↓ PromotionResult(closure_unblocked=True) """ from __future__ import annotations +from collections.abc import Callable +from pathlib import Path + from agentsync.enforcer.enforcer import SkillBuilderEnforcer from agentsync.enforcer.models import ObligationStatus, SkillObligation +from agentsync.skills.registry import approved_skill_conflict, install_approved_skill from agentsync.skills.skill_schema import ValidationLevel, validate_skill_md from .models import PromotionResult, PromotionState, SkillCandidate @@ -37,10 +43,18 @@ def __init__( stele_port: StelePromotionPort, *, validation_level: ValidationLevel = ValidationLevel.PROMOTE, + approved_root: Path | None = None, + on_installed: Callable[[], None] | None = None, ) -> None: + """approved_root: when set, a committed candidate is installed as + approved_root//SKILL.md so the Skill Server can serve it. + on_installed: called after installation (e.g. SkillServer.reload). + """ self._enforcer = enforcer self._stele_port = stele_port self._level = validation_level + self._approved_root = approved_root + self._on_installed = on_installed def promote(self, token: str, candidate: SkillCandidate) -> PromotionResult: """Run the full promotion pipeline. @@ -49,7 +63,8 @@ def promote(self, token: str, candidate: SkillCandidate) -> PromotionResult: 1. Resolve obligation — must exist and be SUBMITTED. 2. Validate candidate SKILL.md at the configured level. 3. Call StelePromotionPort.commit_artifact(). - 4. COMMITTED → enforcer.redeem() → closure_unblocked=True. + 4. COMMITTED → install into approved/ (if configured) + → enforcer.redeem() → closure_unblocked=True. 5. FAILED → PromotionResult(closure_unblocked=False). 6. INVALIDATED→ PromotionResult(closure_unblocked=False). @@ -74,20 +89,34 @@ def promote(self, token: str, candidate: SkillCandidate) -> PromotionResult: dir_name=candidate.dir_name, file_name=candidate.file_name, ) - if not validation.valid: + errors = list(validation.errors) + if self._approved_root is not None: + conflict = approved_skill_conflict( + candidate.dir_name, candidate.content, self._approved_root) + if conflict: + errors.append(conflict) + if errors: return PromotionResult( closure_unblocked=False, state=None, - validation_errors=validation.errors, + validation_errors=errors, note="SKILL.md validation failed; Stele not called", ) # Step 3: commit through the port — thread obl.run_id as the ledger join key artifact = self._stele_port.commit_artifact(candidate, obl.run_id) - # Step 4: COMMITTED — redeem and unblock closure + # Step 4: COMMITTED — install into the catalog, redeem, unblock closure if artifact.state is PromotionState.COMMITTED: - self._enforcer.redeem(token, artifact_hash=artifact.artifact_hash) + skill_id = None + if self._approved_root is not None: + install_approved_skill( + candidate.dir_name, candidate.content, self._approved_root) + skill_id = candidate.dir_name + if self._on_installed is not None: + self._on_installed() + self._enforcer.redeem( + token, artifact_hash=artifact.artifact_hash, skill_id=skill_id) return PromotionResult( closure_unblocked=True, state=PromotionState.COMMITTED, diff --git a/src/agentsync/mcp/server.py b/src/agentsync/mcp/server.py index 2c97fab..ab775e1 100644 --- a/src/agentsync/mcp/server.py +++ b/src/agentsync/mcp/server.py @@ -42,6 +42,7 @@ AGENTSYNC_APPROVED_ROOT path to approved/ skills directory AGENTSYNC_OBLIGATIONS_PATH path to obligations.jsonl AGENTSYNC_STELE_DB_PATH path to Stele SQLite ledger DB +AGENTSYNC_STELE_ARCHIVE_ROOT path to the Stele evidence archive (BlobStore) AGENTSYNC_ARTIFACTS_BASE base directory for Stele artifact writes AGENTSYNC_TRANSPORT stdio (default) | http AGENTSYNC_PORT HTTP port when transport=http (default 8080) @@ -86,6 +87,10 @@ "AGENTSYNC_STELE_DB_PATH", str(_REPO_ROOT / "storage" / "stele" / "ledger.db"), )) +_STELE_ARCHIVE_ROOT = Path(os.environ.get( + "AGENTSYNC_STELE_ARCHIVE_ROOT", + str(_REPO_ROOT / "storage" / "stele" / "archive"), +)) _ARTIFACTS_BASE = Path(os.environ.get( "AGENTSYNC_ARTIFACTS_BASE", str(_REPO_ROOT / "storage" / "stele" / "artifacts"), @@ -117,20 +122,27 @@ from agentsync.enforcer.enforcer import SkillBuilderEnforcer from agentsync.enforcer.models import EnforcementResult, SkillObligation +# The Enforcer confirms gaps against the same index the skill tools serve, so a +# skill installed by promotion is visible to both after one reload. _ENFORCER = SkillBuilderEnforcer( - approved_root=_APPROVED_ROOT, + lookup=_SKILL_SERVER, obligations_path=_OBLIGATIONS_PATH, ) # Kanon + Stele port (stele is an optional dep — fail loud at startup if missing) +from stele.archive.store import BlobStore from stele.ledger.store import LedgerStore from agentsync.kanon.evecor_stele_port import EvecorStelePort from agentsync.kanon.models import SkillCandidate from agentsync.kanon.promotion_adapter import PromotionAdapter -_STORE = LedgerStore(_STELE_DB_PATH) +_STORE = LedgerStore(_STELE_DB_PATH, BlobStore(_STELE_ARCHIVE_ROOT)) _PORT = EvecorStelePort(_STORE, _ARTIFACTS_BASE) -_ADAPTER = PromotionAdapter(_ENFORCER, _PORT) +_ADAPTER = PromotionAdapter( + _ENFORCER, _PORT, + approved_root=_APPROVED_ROOT, + on_installed=_SKILL_SERVER.reload, +) # --------------------------------------------------------------------------- @@ -390,6 +402,7 @@ def _ensure_storage_dirs() -> None: _OBLIGATIONS_PATH.parent.mkdir(parents=True, exist_ok=True) _APPROVED_ROOT.mkdir(parents=True, exist_ok=True) _STELE_DB_PATH.parent.mkdir(parents=True, exist_ok=True) + _STELE_ARCHIVE_ROOT.mkdir(parents=True, exist_ok=True) _ARTIFACTS_BASE.mkdir(parents=True, exist_ok=True) diff --git a/src/agentsync/skills/registry.py b/src/agentsync/skills/registry.py index 2ee89bf..eb978e5 100644 --- a/src/agentsync/skills/registry.py +++ b/src/agentsync/skills/registry.py @@ -66,3 +66,46 @@ def load_approved_skills(approved_root: Path | None = None) -> dict[str, Approve raw=raw, ) return out + + +class SkillConflictError(Exception): + """An approved skill with this canonical_id already exists with other content.""" + + +def approved_skill_conflict( + canonical_id: str, content: str, approved_root: Path | None = None, +) -> str | None: + """Return why `content` cannot be installed as `canonical_id`, or None. + + Installing identical content again is allowed (idempotent retry); replacing + an existing approved skill with different content is not. + """ + skill_file = (approved_root or DEFAULT_APPROVED_ROOT) / canonical_id / SKILL_FILENAME + if skill_file.exists() and skill_file.read_text(encoding="utf-8") != content: + return f"approved skill {canonical_id!r} already exists with different content" + return None + + +def install_approved_skill( + canonical_id: str, content: str, approved_root: Path | None = None, +) -> Path: + """Write content to approved//SKILL.md atomically. + + Called by Kanon after Stele seals the candidate; the Skill Server itself + never writes here. Raises SkillConflictError instead of overwriting a + different approved skill. + """ + if not canonical_id or canonical_id in (".", "..") or Path(canonical_id).name != canonical_id: + raise ValueError(f"canonical_id must be a single directory name: {canonical_id!r}") + root = approved_root or DEFAULT_APPROVED_ROOT + conflict = approved_skill_conflict(canonical_id, content, root) + if conflict: + raise SkillConflictError(conflict) + + skill_dir = root / canonical_id + skill_dir.mkdir(parents=True, exist_ok=True) + skill_file = skill_dir / SKILL_FILENAME + tmp = skill_dir / f".{SKILL_FILENAME}.tmp" + tmp.write_text(content, encoding="utf-8") + tmp.replace(skill_file) + return skill_file diff --git a/tests/test_evecor_stele_port.py b/tests/test_evecor_stele_port.py index b883e59..105e453 100644 --- a/tests/test_evecor_stele_port.py +++ b/tests/test_evecor_stele_port.py @@ -16,7 +16,9 @@ import pytest +from stele.archive.store import BlobStore from stele.ledger.hashing import sha256_file, sha256_manifest +from stele.ledger.models import ArtifactState from stele.ledger.store import LedgerStore from agentsync.enforcer.enforcer import SkillBuilderEnforcer @@ -54,7 +56,7 @@ # Process 1. Submit candidate via PromotionAdapter.promote(). 2. EvecorStelePort writes SKILL.md to disk. -3. LedgerStore creates a PENDING record then commits it. +3. LedgerStore creates a PENDING record then seals it. 4. PromotionAdapter redeems the obligation token. # Verification @@ -94,9 +96,13 @@ def artifact_base(tmp_path: Path) -> Path: return tmp_path / "artifacts" +def _ledger(tmp_path: Path) -> LedgerStore: + return LedgerStore(tmp_path / "stele.db", BlobStore(tmp_path / "stele-archive")) + + @pytest.fixture def store(tmp_path: Path) -> LedgerStore: - return LedgerStore(tmp_path / "stele.db") + return _ledger(tmp_path) @pytest.fixture @@ -173,21 +179,18 @@ def test_evecor_port_preserves_join_key( # --------------------------------------------------------------------------- -# 4. Duplicate content behavior is explicit +# 4. Duplicate content and retries # --------------------------------------------------------------------------- -def test_evecor_port_duplicate_content_behavior_is_explicit( +def test_evecor_port_duplicate_content_gets_one_record_per_run( port: EvecorStelePort, + store: LedgerStore, simple_candidate: SkillCandidate, ) -> None: - """Same content, different run_ids → both return COMMITTED (duplicate_policy='ignore'). - - Locked behavior: - - First commit writes the artifact and creates the COMMITTED record. - - Second commit hits the duplicate guard (same artifact_hash already committed) - and returns the existing record rather than raising DuplicateArtifactError. - - Both calls return state=COMMITTED and preserve their own run_id. - - Both calls share the same artifact_hash (content is identical). + """Same content, different run_ids → each run gets its own sealed record. + + Stele stores the content once in the archive; each record keeps its own + provenance, so both obligations join back to a record by their run_id. """ rid1 = str(uuid.uuid4()) rid2 = str(uuid.uuid4()) @@ -197,16 +200,91 @@ def test_evecor_port_duplicate_content_behavior_is_explicit( assert a1.state is PromotionState.COMMITTED assert a2.state is PromotionState.COMMITTED + assert a1.run_id == rid1 and a2.run_id == rid2 + assert a1.artifact_hash == a2.artifact_hash + assert a1.record_id != a2.record_id - # Each call preserves its own join key - assert a1.run_id == rid1 - assert a2.run_id == rid2 + assert store.get_by_run_id(rid1).record_id == a1.record_id + assert store.get_by_run_id(rid2).record_id == a2.record_id - # Identical content → identical artifact_hash - assert a1.artifact_hash == a2.artifact_hash - # record_id may be the same (existing record returned for duplicate) — that's fine - # The important thing is both callers get COMMITTED, not FAILED +def test_evecor_port_retry_same_run_same_content_is_idempotent( + port: EvecorStelePort, + store: LedgerStore, + simple_candidate: SkillCandidate, + run_id: str, +) -> None: + """A retry after a crash between seal and redeem returns the sealed record.""" + first = port.commit_artifact(simple_candidate, run_id) + again = port.commit_artifact(simple_candidate, run_id) + + assert again.state is PromotionState.COMMITTED + assert again.record_id == first.record_id + assert again.artifact_hash == first.artifact_hash + assert store.get_by_run_id(run_id).state is ArtifactState.SEALED + + +def test_evecor_port_retry_same_run_different_content_fails( + port: EvecorStelePort, + store: LedgerStore, + simple_candidate: SkillCandidate, + run_id: str, + artifact_base: Path, +) -> None: + """A run_id is bound to one artifact; different content is refused, not overwritten.""" + first = port.commit_artifact(simple_candidate, run_id) + other = SkillCandidate(content="different content", dir_name="test-skill") + + result = port.commit_artifact(other, run_id) + + assert result.state is PromotionState.FAILED + assert result.record_id == first.record_id + assert (artifact_base / run_id / "SKILL.md").read_text(encoding="utf-8") == SIMPLE_CONTENT + assert store.get_by_run_id(run_id).state is ArtifactState.SEALED + + +def test_evecor_port_resumes_pending_record( + port: EvecorStelePort, + store: LedgerStore, + simple_candidate: SkillCandidate, + run_id: str, + artifact_base: Path, +) -> None: + """A record left PENDING (crash before seal) is sealed by the retry.""" + skill_path = artifact_base / run_id / "SKILL.md" + skill_path.parent.mkdir(parents=True) + skill_path.write_text(SIMPLE_CONTENT, encoding="utf-8") + pending = store.create_pending( + run_id=run_id, + artifact_dir=skill_path.parent, + artifact_paths=[skill_path], + parser=port._producer, + parser_config={"validation_level": "promote"}, + ) + + result = port.commit_artifact(simple_candidate, run_id) + + assert result.state is PromotionState.COMMITTED + assert result.record_id == pending.record_id + assert store.get(pending.record_id).state is ArtifactState.SEALED + + +def test_evecor_port_records_kanon_as_producer( + port: EvecorStelePort, + store: LedgerStore, + simple_candidate: SkillCandidate, + run_id: str, +) -> None: + port.commit_artifact(simple_candidate, run_id) + record = store.get_by_run_id(run_id) + + assert record.parser.name == "agentsync-kanon" + assert record.parser_config == { + "validation_level": "promote", + "dir_name": "test-skill", + "file_name": "SKILL.md", + } + assert record.source_hash is None # no input document was parsed # --------------------------------------------------------------------------- @@ -241,7 +319,7 @@ def test_promotion_adapter_with_real_evecor_port_redeems_token( Enforcer SUBMITTED obligation → Kanon validates SKILL.md (PROMOTE level) - → EvecorStelePort writes file + LedgerStore.create_pending + commit + → EvecorStelePort writes file + LedgerStore.create_pending + seal → PromotionAdapter redeems token → closure_unblocked=True """ @@ -258,7 +336,7 @@ def test_promotion_adapter_with_real_evecor_port_redeems_token( enforcer.submit_candidate(token, "staging/T-E2E-REAL/SKILL.md") # Wire the real port - store = LedgerStore(tmp_path / "stele.db") + store = _ledger(tmp_path) real_port = EvecorStelePort(store, tmp_path / "artifacts") adapter = PromotionAdapter(enforcer, real_port) @@ -287,6 +365,7 @@ def test_promotion_adapter_with_real_evecor_port_redeems_token( assert redeemed.redeemed_artifact_hash == promote_result.artifact.artifact_hash # Stele ledger confirms the record - records = store.find_by_run_id(obl.run_id) - assert len(records) == 1 - assert records[0].artifact_hash == promote_result.artifact.artifact_hash + record = store.get_by_run_id(obl.run_id) + assert record is not None + assert record.state is ArtifactState.SEALED + assert record.artifact_hash == promote_result.artifact.artifact_hash diff --git a/tests/test_local_service_smoke.py b/tests/test_local_service_smoke.py index 4e94e90..aeb86d6 100644 --- a/tests/test_local_service_smoke.py +++ b/tests/test_local_service_smoke.py @@ -13,7 +13,7 @@ Kanon validates at PROMOTE level ↓ valid=True EvecorStelePort commits through real LedgerStore - ↓ ArtifactState.COMMITTED + ↓ ArtifactState.SEALED (reported as PromotionState.COMMITTED) PromotionAdapter redeems token ↓ closure_unblocked=True @@ -27,6 +27,7 @@ import pytest +from stele.archive.store import BlobStore from stele.ledger.store import LedgerStore from agentsync.enforcer.enforcer import SkillBuilderEnforcer @@ -219,7 +220,7 @@ def test_smoke_full_loop_obligation_to_redemption(tmp_path: Path) -> None: assert enforcer.get_obligation(token).status is ObligationStatus.SUBMITTED # ---- Step 4: Kanon validates → EvecorStelePort commits → token redeemed ------ - store = LedgerStore(tmp_path / "stele.db") + store = LedgerStore(tmp_path / "stele.db", BlobStore(tmp_path / "stele-archive")) port = EvecorStelePort(store, tmp_path / "artifacts") adapter = PromotionAdapter(enforcer, port) @@ -256,11 +257,9 @@ def test_smoke_full_loop_obligation_to_redemption(tmp_path: Path) -> None: assert redeemed.redeemed_artifact_hash == artifact.artifact_hash # Stele ledger confirms the record via run_id join - records = store.find_by_run_id(obl.run_id) - assert len(records) == 1, ( - f"Expected 1 Stele record for run_id={obl.run_id}, got {len(records)}" - ) - assert records[0].artifact_hash == artifact.artifact_hash + record = store.get_by_run_id(obl.run_id) + assert record is not None, f"No Stele record for run_id={obl.run_id}" + assert record.artifact_hash == artifact.artifact_hash # SKILL.md written to expected path inside tmp_path skill_path = tmp_path / "artifacts" / obl.run_id / "SKILL.md" diff --git a/tests/test_mcp_service_smoke.py b/tests/test_mcp_service_smoke.py index 02c9e8a..6ceadac 100644 --- a/tests/test_mcp_service_smoke.py +++ b/tests/test_mcp_service_smoke.py @@ -29,6 +29,7 @@ import json import os +import shutil import sys from pathlib import Path from typing import Any @@ -104,15 +105,22 @@ # --------------------------------------------------------------------------- def _server_env(tmp_path: Path) -> dict[str, str]: - """Env dict that points all mutable state at tmp_path.""" + """Env dict that points all mutable state at tmp_path. + + approved/ is copied into tmp_path because promotion installs into it. + """ + approved = tmp_path / "approved" + if not approved.exists(): + shutil.copytree(APPROVED_PATH, approved) return { **os.environ, "AGENTSYNC_RULES_PATH": str(RULES_PATH), "AGENTSYNC_CAPABILITIES_PATH": str(CAPS_PATH), "AGENTSYNC_PRE_OBLIGATIONS_PATH": str(tmp_path / "pre_obligations.jsonl"), - "AGENTSYNC_APPROVED_ROOT": str(APPROVED_PATH), + "AGENTSYNC_APPROVED_ROOT": str(approved), "AGENTSYNC_OBLIGATIONS_PATH": str(tmp_path / "obligations.jsonl"), "AGENTSYNC_STELE_DB_PATH": str(tmp_path / "stele.db"), + "AGENTSYNC_STELE_ARCHIVE_ROOT": str(tmp_path / "stele-archive"), "AGENTSYNC_ARTIFACTS_BASE": str(tmp_path / "artifacts"), "AGENTSYNC_TRANSPORT": "stdio", } @@ -256,6 +264,7 @@ def test_mcp_full_loop(tmp_path: Path) -> None: Step 4 agentsync_submit_candidate_skill → status=submitted Step 5 agentsync_promote_candidate_skill → closure_unblocked=True Step 6 agentsync_get_obligation → status=redeemed, closure_blocked=False + Step 7 the promoted skill is served, and re-enforcing the task → covered """ task_description = ( "Provision a brand new birthright entitlement set for a new employee identity" @@ -353,6 +362,20 @@ async def _run(): final_obl["redeemed_artifact_hash"] == promotion["artifact"]["artifact_hash"] ) + assert final_obl["redeemed_skill_id"] == SKILL_DIR_NAME + + # ---- Step 7: the loop closes ------------------------------------ + skills = _parse(await session.call_tool("agentsync_list_skills", {})) + if isinstance(skills, dict): # one skill -> one content block + skills = skills.get("result", [skills]) + assert SKILL_DIR_NAME in {s["canonical_id"] for s in skills} + + again = _parse(await session.call_tool( + "agentsync_enforce_task", + {"evaluation": evaluation, "task_description": task_description}, + )) + assert again["outcome"] == "covered", again + assert again["existing_skill_id"] == SKILL_DIR_NAME return promotion, obl["run_id"] @@ -364,3 +387,6 @@ async def _run(): f"SKILL.md not found at expected path: {skill_path}" ) assert skill_path.read_text(encoding="utf-8") == VALID_SKILL_MD + installed = tmp_path / "approved" / SKILL_DIR_NAME / "SKILL.md" + assert installed.read_text(encoding="utf-8") == VALID_SKILL_MD + assert not (APPROVED_PATH / SKILL_DIR_NAME).exists() diff --git a/tests/test_operational_hardening.py b/tests/test_operational_hardening.py index e12c67f..f9a115a 100644 --- a/tests/test_operational_hardening.py +++ b/tests/test_operational_hardening.py @@ -29,6 +29,7 @@ def test_bootstrap_storage_creates_dirs(tmp_path: Path) -> None: assert (storage / "obligations").is_dir() assert (storage / "skills" / "approved").is_dir() assert (storage / "stele" / "artifacts").is_dir() + assert (storage / "stele" / "archive").is_dir() def test_backup_and_restore_roundtrip(tmp_path: Path) -> None: @@ -46,6 +47,9 @@ def test_backup_and_restore_roundtrip(tmp_path: Path) -> None: artifact_dir = storage / "stele" / "artifacts" / "run-1" artifact_dir.mkdir(parents=True) (artifact_dir / "SKILL.md").write_text("# skill", encoding="utf-8") + archive_blob = storage / "stele" / "archive" / "blobs" / "ab" / "abcd" + archive_blob.parent.mkdir(parents=True) + archive_blob.write_bytes(b"blob") backup_dir = tmp_path / "backups" result = subprocess.run( @@ -74,6 +78,7 @@ def test_backup_and_restore_roundtrip(tmp_path: Path) -> None: assert obl_file.read_text(encoding="utf-8") == '{"token":"t1"}\n' assert ledger.read_bytes() == b"sqlite-test" assert (artifact_dir / "SKILL.md").read_text(encoding="utf-8") == "# skill" + assert archive_blob.read_bytes() == b"blob" def test_main_ensure_storage_dirs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: @@ -82,6 +87,7 @@ def test_main_ensure_storage_dirs(tmp_path: Path, monkeypatch: pytest.MonkeyPatc monkeypatch.setenv("AGENTSYNC_APPROVED_ROOT", str(tmp_path / "skills/approved")) monkeypatch.setenv("AGENTSYNC_STELE_DB_PATH", str(tmp_path / "stele/ledger.db")) monkeypatch.setenv("AGENTSYNC_ARTIFACTS_BASE", str(tmp_path / "stele/artifacts")) + monkeypatch.setenv("AGENTSYNC_STELE_ARCHIVE_ROOT", str(tmp_path / "stele/archive")) import agentsync.mcp.server as server @@ -91,6 +97,7 @@ def test_main_ensure_storage_dirs(tmp_path: Path, monkeypatch: pytest.MonkeyPatc assert (tmp_path / "obligations").is_dir() assert (tmp_path / "skills" / "approved").is_dir() assert (tmp_path / "stele" / "artifacts").is_dir() + assert (tmp_path / "stele" / "archive").is_dir() # Reload with defaults so later tests are unaffected for key in ( @@ -99,6 +106,7 @@ def test_main_ensure_storage_dirs(tmp_path: Path, monkeypatch: pytest.MonkeyPatc "AGENTSYNC_APPROVED_ROOT", "AGENTSYNC_STELE_DB_PATH", "AGENTSYNC_ARTIFACTS_BASE", + "AGENTSYNC_STELE_ARCHIVE_ROOT", ): monkeypatch.delenv(key, raising=False) importlib.reload(server) diff --git a/tests/test_promotion_catalog.py b/tests/test_promotion_catalog.py new file mode 100644 index 0000000..f4dadad --- /dev/null +++ b/tests/test_promotion_catalog.py @@ -0,0 +1,206 @@ +"""Promotion installs into the approved catalog and closes the loop. + +Runs without Stele: promotion goes through FakeStelePromotionPort. All +writes go to pytest tmp_path. + + enforce → obligation_open + submit → SUBMITTED + promote → installed at approved//SKILL.md, index reloaded, REDEEMED + enforce → covered by the installed skill (no second obligation) +""" + +from pathlib import Path + +import pytest + +from agentsync.enforcer.enforcer import SkillBuilderEnforcer +from agentsync.enforcer.models import ObligationStatus +from agentsync.kanon.models import PromotionState, SkillCandidate +from agentsync.kanon.promotion_adapter import PromotionAdapter +from agentsync.kanon.stele_port import FakeStelePromotionPort +from agentsync.rules.models import EnforcementLevel, MatchedRule, ResolverPath, RuleEvaluation +from agentsync.skills.registry import SkillConflictError, install_approved_skill +from agentsync.skills.skill_server import SkillServer + +REQUIRED_EVIDENCE = ["dry_run_output", "rollback_step"] +DIR_NAME = "vendor-offboarding-runbook" +TASK_DESCRIPTION = "revoke all entitlements for a departing contractor" + +VALID_SKILL_MD = """\ +--- +name: vendor-offboarding-runbook +description: Offboard a vendor account safely. Use when a contractor leaves. +--- + +# Overview +Removes access for a departing contractor. + +# When to Use +Use when a contractor engagement ends. + +# Process +1. Export current entitlements. +2. Revoke them. + +# Verification +- dry_run_output attached before revocation +- rollback_step recorded for each revoked entitlement +""" + + +def _eval(task_id: str) -> RuleEvaluation: + return RuleEvaluation( + task_id=task_id, + matched_rules=[MatchedRule( + rule_id="iam-test", + skill_expected=True, + skill_family="iam-lifecycle", + capability_family="identity-governance", + required_evidence=REQUIRED_EVIDENCE, + enforcement_level=EnforcementLevel.BLOCK, + )], + resolver_path=ResolverPath.PATTERN, + skill_expected=True, + skill_family="iam-lifecycle", + capability_family="identity-governance", + required_evidence=REQUIRED_EVIDENCE, + enforcement_level=EnforcementLevel.BLOCK, + closure_blocked_if_missing_skill=True, + ) + + +@pytest.fixture +def approved(tmp_path: Path) -> Path: + root = tmp_path / "approved" + root.mkdir() + return root + + +@pytest.fixture +def server(approved: Path) -> SkillServer: + return SkillServer(approved_root=approved) + + +@pytest.fixture +def enforcer(tmp_path: Path, server: SkillServer) -> SkillBuilderEnforcer: + return SkillBuilderEnforcer(lookup=server, obligations_path=tmp_path / "obligations.jsonl") + + +def _submitted_token(enforcer: SkillBuilderEnforcer, task_id: str) -> str: + result = enforcer.enforce(_eval(task_id), task_description=TASK_DESCRIPTION) + assert result.outcome == "obligation_open" + enforcer.submit_candidate(result.skill_obligation_token, f"staging/{task_id}/SKILL.md") + return result.skill_obligation_token + + +def _candidate(content: str = VALID_SKILL_MD) -> SkillCandidate: + return SkillCandidate( + content=content, dir_name=DIR_NAME, required_evidence=REQUIRED_EVIDENCE, + ) + + +def test_promotion_installs_skill_and_closes_loop( + enforcer: SkillBuilderEnforcer, server: SkillServer, approved: Path, +) -> None: + token = _submitted_token(enforcer, "T-LOOP") + adapter = PromotionAdapter( + enforcer, FakeStelePromotionPort(), + approved_root=approved, on_installed=server.reload, + ) + + result = adapter.promote(token, _candidate()) + + assert result.closure_unblocked is True + installed = approved / DIR_NAME / "SKILL.md" + assert installed.read_text(encoding="utf-8") == VALID_SKILL_MD + assert not list((approved / DIR_NAME).glob(".*.tmp")) + + obl = enforcer.get_obligation(token) + assert obl.status is ObligationStatus.REDEEMED + assert obl.redeemed_skill_id == DIR_NAME + + # Served by the same index the Enforcer uses — no restart needed. + assert DIR_NAME in {s.canonical_id for s in server.list_skills()} + + # Re-enforcing the task is covered by the skill it produced. + again = enforcer.enforce(_eval("T-LOOP"), task_description=TASK_DESCRIPTION) + assert again.outcome == "covered" + assert again.existing_skill_id == DIR_NAME + assert again.skill_obligation_token is None + + +def test_redeemed_task_reobligates_if_skill_removed( + enforcer: SkillBuilderEnforcer, server: SkillServer, approved: Path, +) -> None: + token = _submitted_token(enforcer, "T-GONE") + PromotionAdapter( + enforcer, FakeStelePromotionPort(), + approved_root=approved, on_installed=server.reload, + ).promote(token, _candidate()) + + (approved / DIR_NAME / "SKILL.md").unlink() + server.reload() + + again = enforcer.enforce(_eval("T-GONE"), task_description=TASK_DESCRIPTION) + assert again.outcome == "obligation_open" + assert again.skill_obligation_token != token + + +def test_conflicting_dir_name_rejected_before_stele( + enforcer: SkillBuilderEnforcer, server: SkillServer, approved: Path, +) -> None: + install_approved_skill(DIR_NAME, "existing different skill", approved) + token = _submitted_token(enforcer, "T-CONFLICT") + port = FakeStelePromotionPort() + adapter = PromotionAdapter(enforcer, port, approved_root=approved) + + result = adapter.promote(token, _candidate()) + + assert result.closure_unblocked is False + assert result.state is None + assert any("already exists" in e for e in result.validation_errors) + assert port.call_count() == 0 + assert (approved / DIR_NAME / "SKILL.md").read_text(encoding="utf-8") == "existing different skill" + assert enforcer.get_obligation(token).status is ObligationStatus.SUBMITTED + + +def test_reinstalling_identical_content_is_allowed(approved: Path) -> None: + install_approved_skill(DIR_NAME, VALID_SKILL_MD, approved) + path = install_approved_skill(DIR_NAME, VALID_SKILL_MD, approved) + assert path.read_text(encoding="utf-8") == VALID_SKILL_MD + + with pytest.raises(SkillConflictError): + install_approved_skill(DIR_NAME, "other", approved) + + +def test_failed_commit_installs_nothing( + enforcer: SkillBuilderEnforcer, approved: Path, +) -> None: + token = _submitted_token(enforcer, "T-FAIL") + adapter = PromotionAdapter( + enforcer, FakeStelePromotionPort(outcome=PromotionState.FAILED), + approved_root=approved, + ) + + result = adapter.promote(token, _candidate()) + + assert result.closure_unblocked is False + assert not (approved / DIR_NAME).exists() + assert enforcer.get_obligation(token).status is ObligationStatus.SUBMITTED + + +def test_adapter_without_approved_root_does_not_install( + enforcer: SkillBuilderEnforcer, approved: Path, +) -> None: + token = _submitted_token(enforcer, "T-NOROOT") + result = PromotionAdapter(enforcer, FakeStelePromotionPort()).promote(token, _candidate()) + + assert result.closure_unblocked is True + assert not (approved / DIR_NAME).exists() + assert enforcer.get_obligation(token).redeemed_skill_id is None + + +@pytest.mark.parametrize("bad", ["", "..", "../escape", "a/b"]) +def test_install_rejects_paths_outside_approved(approved: Path, bad: str) -> None: + with pytest.raises(ValueError): + install_approved_skill(bad, VALID_SKILL_MD, approved) From c108b47fd45b2b7c616686021373c139053417b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 05:02:34 +0000 Subject: [PATCH 2/3] Pin mcp below 2 mcp 2.x renamed FastMCP to MCPServer, so with `mcp[cli]>=1.0` a fresh install resolves to 2.x and agentsync.mcp.server fails at startup (ModuleNotFoundError: mcp.server.fastmcp). Cap the mcp and dev extras at <2 until the server is ported to the 2.x API. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VnaMtsgmbeB8QWCrqdpSWc --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b99b3e7..7d12a92 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,8 +9,8 @@ dependencies = [ ] [project.optional-dependencies] -mcp = ["mcp[cli]>=1.0"] # FastMCP transport — required to run agentsync.mcp.server -dev = ["pytest>=8.0", "mcp[cli]>=1.0", "anyio>=4.0"] +mcp = ["mcp[cli]>=1.0,<2"] # FastMCP transport — required to run agentsync.mcp.server; mcp 2 renamed FastMCP +dev = ["pytest>=8.0", "mcp[cli]>=1.0,<2", "anyio>=4.0"] # Pinned: EvecorStelePort targets this ledger API (create_pending(parser=...) -> seal). stele = ["stele @ git+https://github.com/Deep-Sixed/Stele@10bbf3f24a4cd4b15fc501c57ce58bb2fcf7b54a"] From 6563e586924936ab411aae1a1c6db08802b01e79 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 26 Sep 2026 07:37:40 +0000 Subject: [PATCH 3/3] Make the test suite pass on a clean clone (#8) Tests read seed skills from storage/skills/approved/, which .gitignore excludes, so a fresh clone failed 12 tests; and four modules imported stele at module load, so without the [stele] extra they errored during collection. - Add committed seed skills under tests/fixtures/approved/ (sailpoint-joiner-provisioning, jamf-ade-enrollment, a frontmatter-less broken-skill, and a directory without SKILL.md) and point the skill server, enforcer, local-service and MCP-service tests at them. - Skip the Stele port, local-service and MCP-service tests, and the server-import storage test, with importorskip("stele") when the [stele] extra is not installed. - README: describe the clean-clone behaviour. Clean clone, no stele: 83 passed, 14 skipped. With the pinned stele: 102 passed, 10 skipped (ContextForge suites only). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VnaMtsgmbeB8QWCrqdpSWc --- README.md | 16 +++++++++----- tests/fixtures/approved/README.md | 9 ++++++++ tests/fixtures/approved/broken-skill/SKILL.md | 3 +++ .../approved/has-non-skill-file/README.md | 1 + .../approved/jamf-ade-enrollment/SKILL.md | 21 ++++++++++++++++++ .../sailpoint-joiner-provisioning/SKILL.md | 22 +++++++++++++++++++ tests/test_enforcer.py | 2 +- tests/test_evecor_stele_port.py | 2 ++ tests/test_local_service_smoke.py | 4 +++- tests/test_mcp_service_smoke.py | 5 ++++- tests/test_operational_hardening.py | 2 ++ tests/test_skill_server.py | 2 +- 12 files changed, 80 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/approved/README.md create mode 100644 tests/fixtures/approved/broken-skill/SKILL.md create mode 100644 tests/fixtures/approved/has-non-skill-file/README.md create mode 100644 tests/fixtures/approved/jamf-ade-enrollment/SKILL.md create mode 100644 tests/fixtures/approved/sailpoint-joiner-provisioning/SKILL.md diff --git a/README.md b/README.md index a446ba0..f9ade33 100644 --- a/README.md +++ b/README.md @@ -148,14 +148,20 @@ uv run pytest tests/test_operational_hardening.py -v uv run pytest tests/test_agent_facing_a2a_smoke.py -v # requires ContextForge ``` -The `test_agent_facing_a2a_smoke.py` and `test_contextforge_smoke.py` suites -require a running ContextForge and a valid `ROUTERCORE_MCP_BEARER_TOKEN`; the -rest run standalone. +The suite runs on a clean clone. Tests read seed skills from the committed +`tests/fixtures/approved/` tree, never from the git-ignored +`storage/skills/approved/`, and write only to pytest's `tmp_path`. + +- Without the `[stele]` extra, the Stele port, local-service and MCP-service + tests skip ("needs the [stele] extra"); with it, they run. +- The `test_agent_facing_a2a_smoke.py` and `test_contextforge_smoke.py` suites + skip unless ContextForge is running and a valid + `ROUTERCORE_MCP_BEARER_TOKEN` is available. ## Frozen checkpoints -Current validated gate: **`v1.1-kanon-operational-hardening` — 98/98** -(HEAD is this tag plus docs-only commits). +Last tagged gate: **`v1.1-kanon-operational-hardening` — 98/98**, on the +reference machine with ContextForge running. | Tag | Gate | Note | |-----|------|------| diff --git a/tests/fixtures/approved/README.md b/tests/fixtures/approved/README.md new file mode 100644 index 0000000..84faea6 --- /dev/null +++ b/tests/fixtures/approved/README.md @@ -0,0 +1,9 @@ +Seed skills for tests. storage/skills/approved/ is runtime data and is +git-ignored, so tests read this committed tree instead: + +- sailpoint-joiner-provisioning, jamf-ade-enrollment: valid approved skills +- broken-skill: SKILL.md without frontmatter (must be skipped) +- has-non-skill-file: directory without a SKILL.md (must be skipped) + +Matcher tests depend on these descriptions (e.g. no word shared with +"water the office plants"), so keep them in step with tests/test_skill_server.py. diff --git a/tests/fixtures/approved/broken-skill/SKILL.md b/tests/fixtures/approved/broken-skill/SKILL.md new file mode 100644 index 0000000..6277e18 --- /dev/null +++ b/tests/fixtures/approved/broken-skill/SKILL.md @@ -0,0 +1,3 @@ +# Broken skill + +This file has no YAML frontmatter, so registry must skip it. diff --git a/tests/fixtures/approved/has-non-skill-file/README.md b/tests/fixtures/approved/has-non-skill-file/README.md new file mode 100644 index 0000000..d9c4d64 --- /dev/null +++ b/tests/fixtures/approved/has-non-skill-file/README.md @@ -0,0 +1 @@ +This directory has no SKILL.md, so registry must skip it. diff --git a/tests/fixtures/approved/jamf-ade-enrollment/SKILL.md b/tests/fixtures/approved/jamf-ade-enrollment/SKILL.md new file mode 100644 index 0000000..dbe2d5e --- /dev/null +++ b/tests/fixtures/approved/jamf-ade-enrollment/SKILL.md @@ -0,0 +1,21 @@ +--- +name: jamf-ade-enrollment +description: Enrolls Apple devices into Jamf Pro through Automated Device Enrollment. Use when devices must be enrolled with a prestage enrollment. +skill_family: apple-device-management +capability_family: endpoint-management +--- + +# Overview +Enrolls devices assigned in Apple Business Manager into Jamf Pro. + +# When to Use +Use when new Apple devices need zero-touch enrollment. + +# Process +1. Assign devices to a Jamf Pro prestage enrollment. +2. Scope configuration profiles to a smart group. +3. Enroll a test device first. + +# Verification +- test_device_result recorded for one enrolled device +- scope_definition lists targeted smart groups diff --git a/tests/fixtures/approved/sailpoint-joiner-provisioning/SKILL.md b/tests/fixtures/approved/sailpoint-joiner-provisioning/SKILL.md new file mode 100644 index 0000000..06df4e9 --- /dev/null +++ b/tests/fixtures/approved/sailpoint-joiner-provisioning/SKILL.md @@ -0,0 +1,22 @@ +--- +name: sailpoint-joiner-provisioning +description: Provisions birthright access for a new joiner in SailPoint IdentityIQ. Use when a joiner event needs its birthright entitlements granted. +skill_family: iam-lifecycle +capability_family: identity-governance +--- + +# Overview +Grants a new joiner their birthright entitlements through a SailPoint joiner workflow. + +# When to Use +Use when an HR joiner event arrives and birthright access has not been provisioned yet. + +# Process +1. Confirm the joiner identity exists in IdentityIQ. +2. Run the joiner workflow in dry-run mode and review its plan. +3. Run the joiner workflow for real. + +# Verification +- dry_run_output reviewed before provisioning +- rollback_step recorded (disable the identity and revoke granted entitlements) +- approval_reference attached to the provisioning request diff --git a/tests/test_enforcer.py b/tests/test_enforcer.py index beb9468..dee5709 100644 --- a/tests/test_enforcer.py +++ b/tests/test_enforcer.py @@ -23,7 +23,7 @@ from agentsync.skills.skill_server import SkillLookupResult, SkillQuery REPO = Path(__file__).resolve().parents[1] -APPROVED = REPO / "storage" / "skills" / "approved" +APPROVED = REPO / "tests" / "fixtures" / "approved" # --- test doubles ----------------------------------------------------------- diff --git a/tests/test_evecor_stele_port.py b/tests/test_evecor_stele_port.py index 105e453..4479caa 100644 --- a/tests/test_evecor_stele_port.py +++ b/tests/test_evecor_stele_port.py @@ -16,6 +16,8 @@ import pytest +pytest.importorskip("stele", reason="needs the [stele] extra") + from stele.archive.store import BlobStore from stele.ledger.hashing import sha256_file, sha256_manifest from stele.ledger.models import ArtifactState diff --git a/tests/test_local_service_smoke.py b/tests/test_local_service_smoke.py index aeb86d6..05940fd 100644 --- a/tests/test_local_service_smoke.py +++ b/tests/test_local_service_smoke.py @@ -27,6 +27,8 @@ import pytest +pytest.importorskip("stele", reason="needs the [stele] extra") + from stele.archive.store import BlobStore from stele.ledger.store import LedgerStore @@ -45,7 +47,7 @@ REPO = Path(__file__).resolve().parents[1] RULES_PATH = REPO / "storage" / "rules" / "rules.yaml" CAPS_PATH = REPO / "storage" / "rules" / "capabilities.yaml" -APPROVED_PATH = REPO / "storage" / "skills" / "approved" +APPROVED_PATH = REPO / "tests" / "fixtures" / "approved" # --------------------------------------------------------------------------- # Task descriptions diff --git a/tests/test_mcp_service_smoke.py b/tests/test_mcp_service_smoke.py index 6ceadac..a28bdec 100644 --- a/tests/test_mcp_service_smoke.py +++ b/tests/test_mcp_service_smoke.py @@ -36,6 +36,9 @@ import anyio import pytest + +# The server imports stele at startup. +pytest.importorskip("stele", reason="needs the [stele] extra") from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client @@ -46,7 +49,7 @@ REPO = Path(__file__).resolve().parents[1] RULES_PATH = REPO / "storage" / "rules" / "rules.yaml" CAPS_PATH = REPO / "storage" / "rules" / "capabilities.yaml" -APPROVED_PATH = REPO / "storage" / "skills" / "approved" +APPROVED_PATH = REPO / "tests" / "fixtures" / "approved" # Canonical tool contract — one service, one registration. EXPECTED_TOOLS = { diff --git a/tests/test_operational_hardening.py b/tests/test_operational_hardening.py index f9a115a..e5bfc3e 100644 --- a/tests/test_operational_hardening.py +++ b/tests/test_operational_hardening.py @@ -82,6 +82,8 @@ def test_backup_and_restore_roundtrip(tmp_path: Path) -> None: def test_main_ensure_storage_dirs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + pytest.importorskip("stele", reason="the server imports stele at startup") + pytest.importorskip("mcp", reason="needs the [mcp] extra") monkeypatch.setenv("AGENTSYNC_PRE_OBLIGATIONS_PATH", str(tmp_path / "obligations/pre.jsonl")) monkeypatch.setenv("AGENTSYNC_OBLIGATIONS_PATH", str(tmp_path / "obligations/obligations.jsonl")) monkeypatch.setenv("AGENTSYNC_APPROVED_ROOT", str(tmp_path / "skills/approved")) diff --git a/tests/test_skill_server.py b/tests/test_skill_server.py index e7f230c..4275ffe 100644 --- a/tests/test_skill_server.py +++ b/tests/test_skill_server.py @@ -17,7 +17,7 @@ ) REPO = Path(__file__).resolve().parents[1] -APPROVED = REPO / "storage" / "skills" / "approved" +APPROVED = REPO / "tests" / "fixtures" / "approved" @pytest.fixture