From 7a854d5042e482db716cf8c6e33185dcce81612a Mon Sep 17 00:00:00 2001 From: bgagent Date: Mon, 27 Jul 2026 13:05:21 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(registry):=20agent=20asset=20catalog?= =?UTF-8?q?=20on=20AgentCore=20=E2=80=94=20provisioning,=20port/adapter,?= =?UTF-8?q?=20API,=20CLI=20(#246)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces the read-side catalog for the central agent asset registry built on AWS Agent Registry (Bedrock AgentCore), with nothing upstream importing the AWS SDK directly: - Provisioning: `AgentRegistryStack` (NestedStack) creates the registry via a custom resource (async CreateRegistry, no L2 in preview); bootstrap IAM + resource-action-map updated (states, cognito group, cloudformation nested stack) with the golden DEPLOYMENT_ROLES.md kept in sync. - Ports & adapters: `RegistryClient` port (TS + Py) with a single `AgentCoreRegistryClient` adapter per language. Native descriptor storage — MCP server.json + `_meta`, AGENT_SKILLS markdown frontmatter, CUSTOM verbatim. - Grammar: `registry://kind/namespace/name@constraint` with mandatory semver pin, mirrored byte-for-byte across ref.ts / ref.py and enforced by the `contracts/registry-resolution/` parity corpus. - API: publish / resolve / list / show routes on TaskApi, gated by two Cognito groups (RegistryPublisher / RegistryApprover); `bgagent registry` CLI. - Wire types (shared/types.ts + cli/types.ts): resolved-asset triple stamped on TaskRecord/TaskDetail/TaskSummary for audit. Integration (orchestrator resolve-step, agent loaders, blueprint asset pins) lands in the follow-up PR that builds on this catalog. --- agent/src/registry/__init__.py | 13 + agent/src/registry/agentcore_client.py | 144 +++++++ agent/src/registry/client.py | 60 +++ agent/src/registry/ref.py | 107 +++++ agent/src/registry/resolver.py | 118 ++++++ agent/src/workflow/validator.py | 12 +- agent/tests/test_registry_agentcore_client.py | 67 +++ .../tests/test_registry_resolution_corpus.py | 69 +++ cdk/bootstrap/BOOTSTRAP_HASH | 2 +- cdk/bootstrap/bootstrap-template.yaml | 21 +- cdk/bootstrap/policies/application.json | 18 + cdk/bootstrap/policies/infrastructure.json | 2 + cdk/package.json | 1 + cdk/src/bootstrap/policies/application.ts | 22 + cdk/src/bootstrap/policies/infrastructure.ts | 4 + cdk/src/bootstrap/resource-action-map.ts | 8 + cdk/src/constructs/registry.ts | 221 ++++++++++ cdk/src/constructs/task-api.ts | 97 ++++- cdk/src/handlers/registry-list.ts | 79 ++++ .../handlers/registry-provisioning/index.ts | 163 ++++++++ cdk/src/handlers/registry-publish.ts | 134 ++++++ cdk/src/handlers/registry-resolve.ts | 73 ++++ cdk/src/handlers/registry-show.ts | 73 ++++ cdk/src/handlers/shared/gateway.ts | 16 + .../shared/registry/agentcore-client.ts | 394 ++++++++++++++++++ cdk/src/handlers/shared/registry/client.ts | 61 +++ cdk/src/handlers/shared/registry/factory.ts | 37 ++ cdk/src/handlers/shared/registry/ref.ts | 121 ++++++ cdk/src/handlers/shared/registry/resolver.ts | 161 +++++++ cdk/src/handlers/shared/registry/types.ts | 147 +++++++ cdk/src/handlers/shared/response.ts | 4 + cdk/src/handlers/shared/types.ts | 81 ++++ cdk/src/stacks/agent.ts | 26 ++ .../__snapshots__/version.test.ts.snap | 2 +- cdk/test/bootstrap/policies.test.ts | 2 + cdk/test/constructs/registry.test.ts | 102 +++++ cdk/test/handlers/registry-handlers.test.ts | 221 ++++++++++ .../handlers/shared/agentcore-client.test.ts | 245 +++++++++++ .../shared/registry-resolution-parity.test.ts | 96 +++++ .../handlers/shared/registry-resolver.test.ts | 93 +++++ cli/src/api-client.ts | 50 +++ cli/src/bin/bgagent.ts | 2 + cli/src/commands/registry.ts | 157 +++++++ cli/src/types.ts | 71 ++++ contracts/registry-resolution/README.md | 68 +++ contracts/registry-resolution/cases.json | 95 +++++ docs/design/DEPLOYMENT_ROLES.md | 20 + docs/design/REGISTRY.md | 193 +++++++++ .../docs/architecture/Deployment-roles.md | 20 + .../src/content/docs/architecture/Registry.md | 197 +++++++++ yarn.lock | 239 +++++++++++ 51 files changed, 4423 insertions(+), 6 deletions(-) create mode 100644 agent/src/registry/__init__.py create mode 100644 agent/src/registry/agentcore_client.py create mode 100644 agent/src/registry/client.py create mode 100644 agent/src/registry/ref.py create mode 100644 agent/src/registry/resolver.py create mode 100644 agent/tests/test_registry_agentcore_client.py create mode 100644 agent/tests/test_registry_resolution_corpus.py create mode 100644 cdk/src/constructs/registry.ts create mode 100644 cdk/src/handlers/registry-list.ts create mode 100644 cdk/src/handlers/registry-provisioning/index.ts create mode 100644 cdk/src/handlers/registry-publish.ts create mode 100644 cdk/src/handlers/registry-resolve.ts create mode 100644 cdk/src/handlers/registry-show.ts create mode 100644 cdk/src/handlers/shared/registry/agentcore-client.ts create mode 100644 cdk/src/handlers/shared/registry/client.ts create mode 100644 cdk/src/handlers/shared/registry/factory.ts create mode 100644 cdk/src/handlers/shared/registry/ref.ts create mode 100644 cdk/src/handlers/shared/registry/resolver.ts create mode 100644 cdk/src/handlers/shared/registry/types.ts create mode 100644 cdk/test/constructs/registry.test.ts create mode 100644 cdk/test/handlers/registry-handlers.test.ts create mode 100644 cdk/test/handlers/shared/agentcore-client.test.ts create mode 100644 cdk/test/handlers/shared/registry-resolution-parity.test.ts create mode 100644 cdk/test/handlers/shared/registry-resolver.test.ts create mode 100644 cli/src/commands/registry.ts create mode 100644 contracts/registry-resolution/README.md create mode 100644 contracts/registry-resolution/cases.json create mode 100644 docs/design/REGISTRY.md create mode 100644 docs/src/content/docs/architecture/Registry.md diff --git a/agent/src/registry/__init__.py b/agent/src/registry/__init__.py new file mode 100644 index 000000000..ce6f0cc0a --- /dev/null +++ b/agent/src/registry/__init__.py @@ -0,0 +1,13 @@ +"""Agent asset registry client + reference grammar (#246). + +The agent is a *read-only* consumer of the registry: it receives already-resolved +assets in its task payload and, where it needs to look one up directly, talks to +the substrate through the ``RegistryClient`` port (never a raw AWS SDK client). + +Public surface: + - ``parse_ref`` / ``ParsedRef`` (``ref``) — the strict ``registry://`` grammar, + mirrored byte-for-byte by ``cdk/src/handlers/shared/registry/ref.ts`` and the + ``contracts/registry-resolution/`` parity corpus. + +See ``docs/design/REGISTRY.md`` and ``ISSUE_246_AGENTCORE_FINDINGS.md``. +""" diff --git a/agent/src/registry/agentcore_client.py b/agent/src/registry/agentcore_client.py new file mode 100644 index 000000000..976c0a8a6 --- /dev/null +++ b/agent/src/registry/agentcore_client.py @@ -0,0 +1,144 @@ +"""Read-side AgentCore implementation of the ``RegistryClient`` port (#246). + +The agent only reads: ``get_record`` and ``resolve``. This is the one Python file +that talks to the AgentCore control plane (via boto3) — everything upstream uses +the port, so a substrate swap is confined here. Mirrors the read half of +``cdk/src/handlers/shared/registry/agentcore-client.ts``. +""" + +from __future__ import annotations + +import json +import re +from typing import TYPE_CHECKING, Any + +from registry.client import RegistryResolutionError, ResolvedAsset +from registry.resolver import select_highest + +if TYPE_CHECKING: + from registry.ref import ParsedRef + +_NAME_SEP = "/" +# Option-A record name is `kind/namespace/name`; the name may itself contain `/`. +_NAME_MIN_PARTS = 2 +# The reverse-DNS key under which ABCA runtime config rides in a native `_meta` +# block (matches RUNTIME_META_KEY in registry/types.ts). +_RUNTIME_META_KEY = "dev.abca.runtime" +# Frontmatter key carrying the runtime payload (JSON) in a native AGENT_SKILLS +# SKILL.md — mirrors SKILL_RUNTIME_FM_KEY in registry/agentcore-client.ts. +_SKILL_RUNTIME_FM_KEY = "x-abca-runtime" +_SKILL_RUNTIME_RE = re.compile(rf"^{_SKILL_RUNTIME_FM_KEY}:\s*'(.+)'\s*$", re.MULTILINE) +_RESOLVABLE_STATUSES = ("APPROVED", "DEPRECATED") + + +class AgentCoreRegistryClient: + """Read-only registry access backed by AgentCore.""" + + def __init__(self, registry_id: str, client: Any) -> None: + # ``client`` is a boto3 ``bedrock-agentcore-control`` client, injected so + # the agent's scoped-session helper (aws_session) owns credential wiring. + self._registry_id = registry_id + self._client = client + + # --- name (Option A) decode ------------------------------------------------- + + @staticmethod + def _decode_name(record_name: str) -> tuple[str, str, str]: + parts = record_name.split(_NAME_SEP) + kind = parts[0] if parts else "" + namespace = parts[1] if len(parts) > 1 else "" + name = _NAME_SEP.join(parts[_NAME_MIN_PARTS:]) if len(parts) > _NAME_MIN_PARTS else "" + return kind, namespace, name + + @staticmethod + def _id_from_arn(arn: str) -> str: + return arn.split("/")[-1] if "/" in arn else arn + + # --- record extraction ------------------------------------------------------ + + def _extract_runtime(self, raw: dict[str, Any]) -> dict[str, Any]: + descriptors = raw.get("descriptors", {}) or {} + descriptor_type = raw.get("descriptorType") + if descriptor_type == "CUSTOM": + body = json.loads(descriptors.get("custom", {}).get("inlineContent", "{}")) + return body.get("runtime", {}) + if descriptor_type == "AGENT_SKILLS": + # SKILL.md is Markdown frontmatter, not JSON — recover the runtime + # from the `x-abca-runtime` frontmatter key (mirrors the TS adapter). + skill_md = ( + descriptors.get("agentSkills", {}).get("skillMd", {}).get("inlineContent", "") + ) + m = _SKILL_RUNTIME_RE.search(skill_md) + return json.loads(m.group(1)) if m else {} + # MCP: JSON server.json with the runtime in a `_meta` block. + inline = descriptors.get("mcp", {}).get("server", {}).get("inlineContent") or "{}" + body = json.loads(inline) + return body.get("_meta", {}).get(_RUNTIME_META_KEY, {}) + + def _list_records(self, kind: str, namespace: str) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + next_token: str | None = None + while True: + kwargs: dict[str, Any] = {"registryId": self._registry_id, "maxResults": 50} + if next_token: + kwargs["nextToken"] = next_token + page = self._client.list_registry_records(**kwargs) + for summary in page.get("registryRecords", []) or []: + dkind, dns, _ = self._decode_name(summary.get("name", "")) + if dkind != kind or dns != namespace: + continue + record_id = ( + self._id_from_arn(summary["recordArn"]) + if summary.get("recordArn") + else summary.get("recordId") + ) + if not record_id: + continue + full = self._client.get_registry_record( + registryId=self._registry_id, recordId=record_id + ) + out.append(full) + next_token = page.get("nextToken") + if not next_token: + break + return out + + # --- port surface ----------------------------------------------------------- + + def get_record( + self, kind: str, namespace: str, name: str, version: str + ) -> dict[str, Any] | None: + for raw in self._list_records(kind, namespace): + _, _, dname = self._decode_name(raw.get("name", "")) + if dname == name and raw.get("recordVersion") == version: + return raw + return None + + def resolve(self, ref: ParsedRef) -> ResolvedAsset: + ref_str = f"registry://{ref.kind}/{ref.namespace}/{ref.name}@{ref.constraint.raw}" + records = self._list_records(ref.kind, ref.namespace) + candidates = [ + r + for r in records + if self._decode_name(r.get("name", ""))[2] == ref.name + and r.get("status") in _RESOLVABLE_STATUSES + ] + by_version = {r.get("recordVersion", ""): r for r in candidates} + winning = select_highest(list(by_version.keys()), ref.constraint) + if winning is None: + raise RegistryResolutionError( + "NO_MATCHING_VERSION", + ref_str, + f"no approved version of {ref.kind}/{ref.namespace}/{ref.name} " + f"satisfies {ref.constraint.raw}", + ) + winner = by_version[winning] + warnings = ["DEPRECATED"] if winner.get("status") == "DEPRECATED" else [] + return ResolvedAsset( + kind=ref.kind, + namespace=ref.namespace, + name=ref.name, + version=winning, + runtime=self._extract_runtime(winner), + warnings=warnings, + ) diff --git a/agent/src/registry/client.py b/agent/src/registry/client.py new file mode 100644 index 000000000..5f41bb9ce --- /dev/null +++ b/agent/src/registry/client.py @@ -0,0 +1,60 @@ +"""The read-side ``RegistryClient`` port for the agent (#246). + +The agent is a read-only consumer: it resolves refs and fetches records, but never +publishes or governs. It talks to the substrate through this Protocol, never a raw +AWS SDK client — the one implementation is ``AgentCoreRegistryClient`` +(``registry.agentcore_client``), so a substrate swap is confined there. + +The write-side verbs (publish / submit / approve) live only on the TypeScript port +(``cdk/src/handlers/shared/registry/client.ts``); the agent has no business +mutating the registry. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Protocol + +if TYPE_CHECKING: + from registry.ref import ParsedRef + + +@dataclass(frozen=True) +class ResolvedAsset: + """One resolved asset: enough to load it without knowing where bytes live.""" + + kind: str + namespace: str + name: str + version: str + runtime: dict[str, Any] + warnings: list[str] = field(default_factory=list) + + +class RegistryResolutionError(Exception): + """Raised when a ref cannot be resolved. ``reason`` matches the TS token set + (``NO_MATCHING_VERSION`` / ``REMOVED`` / ``INVALID_CONSTRAINT`` / + ``INVALID_REGISTRY_REF``) so both languages agree on *why*.""" + + def __init__(self, reason: str, ref: str, message: str) -> None: + super().__init__(message) + self.reason = reason + self.ref = ref + + +class RegistryClient(Protocol): + """Read-only registry access. See the TS port for the full (write) surface.""" + + def get_record( + self, kind: str, namespace: str, name: str, version: str + ) -> dict[str, Any] | None: + """Fetch a single record by exact coordinates, or ``None`` if absent.""" + ... + + def resolve(self, ref: ParsedRef) -> ResolvedAsset: + """Resolve a parsed ref to a single APPROVED (or DEPRECATED+warn) asset. + + Fail-closed: raises ``RegistryResolutionError`` with a specific reason on + any unresolved ref. + """ + ... diff --git a/agent/src/registry/ref.py b/agent/src/registry/ref.py new file mode 100644 index 000000000..e4b18f71c --- /dev/null +++ b/agent/src/registry/ref.py @@ -0,0 +1,107 @@ +"""Strict ``registry://`` reference grammar for the agent asset registry (#246). + +:: + + registry:////@ + kind = [a-z][a-z0-9_]* snake_case: mcp_server, cedar_policy_module + namespace = [a-z][a-z0-9-]* + name = [a-z0-9][a-z0-9._-]* + constraint = [^~]?MAJOR.MINOR.PATCH[-prerelease] exact / caret / tilde only + +The ``@`` pin is MANDATORY (fail-closed: no implicit "latest"). + +This module mirrors ``cdk/src/handlers/shared/registry/ref.ts`` byte-for-byte and +is exercised by the ``contracts/registry-resolution/`` parity corpus. Keep the two +in lockstep — a change here without the matching TS change (or vice versa) is a +parity break that CI must catch. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +# MVP asset kinds the registry loads end-to-end or stages. +REGISTRY_KINDS = ("mcp_server", "cedar_policy_module", "skill") +# Reserved kinds accepted by the grammar but rejected at publish (no loader yet). +RESERVED_KINDS = ("plugin", "subagent", "prompt_fragment", "capability") + +# Structural split — scheme + 3 path segments + the (mandatory) constraint. +_REF_SHAPE = re.compile( + r"^registry://([a-z][a-z0-9_]*)/([a-z][a-z0-9-]*)/([a-z0-9][a-z0-9._-]*)@(.+)$" +) +# exact / caret / tilde over MAJOR.MINOR.PATCH with an optional prerelease. +# Rejects ``*``, ``latest``, ``>=``, ``<=``, x-ranges, and bare prerelease modifiers. +_CONSTRAINT = re.compile( + r"^([\^~]?)(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?$" +) +_OP_BY_PREFIX = {"": "exact", "^": "caret", "~": "tilde"} + + +class RefError(ValueError): + """Raised when a ``registry://`` ref is malformed. + + ``reason`` is one of ``INVALID_REGISTRY_REF`` / ``INVALID_CONSTRAINT`` — the + same reason tokens the TS resolver reports, so both sides agree on *why* a + ref failed, not just *that* it failed. + """ + + def __init__(self, reason: str, message: str) -> None: + super().__init__(message) + self.reason = reason + + +@dataclass(frozen=True) +class ParsedConstraint: + op: str # exact | caret | tilde + major: int + minor: int + patch: int + prerelease: str | None + raw: str + + +@dataclass(frozen=True) +class ParsedRef: + kind: str + namespace: str + name: str + constraint: ParsedConstraint + + +def parse_constraint(raw: str) -> ParsedConstraint | None: + """Parse + validate a constraint string in isolation. ``None`` if invalid.""" + m = _CONSTRAINT.match(raw) + if not m: + return None + prefix, major, minor, patch, prerelease = m.groups() + return ParsedConstraint( + op=_OP_BY_PREFIX[prefix], + major=int(major), + minor=int(minor), + patch=int(patch), + prerelease=prerelease, + raw=raw, + ) + + +def parse_ref(ref: str) -> ParsedRef: + """Parse a strict ``registry://kind/namespace/name@constraint`` reference. + + Raises ``RefError`` (with a ``reason``) on a malformed ref or a floating / + unsupported constraint — pins are mandatory. + """ + shape = _REF_SHAPE.match(ref) + if not shape: + raise RefError( + "INVALID_REGISTRY_REF", + f"not a valid registry ref (expected registry://kind/namespace/name@constraint): {ref}", + ) + kind, namespace, name, raw_constraint = shape.groups() + constraint = parse_constraint(raw_constraint) + if constraint is None: + raise RefError( + "INVALID_CONSTRAINT", + f"unsupported version constraint '{raw_constraint}' (use exact, ^, or ~)", + ) + return ParsedRef(kind=kind, namespace=namespace, name=name, constraint=constraint) diff --git a/agent/src/registry/resolver.py b/agent/src/registry/resolver.py new file mode 100644 index 000000000..3ba02afab --- /dev/null +++ b/agent/src/registry/resolver.py @@ -0,0 +1,118 @@ +"""Semver constraint matching + highest-version selection (#246). + +Mirrors ``cdk/src/handlers/shared/registry/resolver.ts`` — AgentCore stores a plain +version string with no native ``^``/``~`` matching, so both the orchestrator (TS) +and the agent (Python) rank in code, and must agree. The parity corpus +(``contracts/registry-resolution/``) covers the shared cases. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from registry.ref import ParsedConstraint + +_SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?$") + + +@dataclass(frozen=True) +class SemVer: + major: int + minor: int + patch: int + prerelease: tuple[str, ...] + raw: str + + +def parse_version(raw: str) -> SemVer | None: + m = _SEMVER.match(raw) + if not m: + return None + major, minor, patch, pre = m.groups() + return SemVer( + major=int(major), + minor=int(minor), + patch=int(patch), + prerelease=tuple(pre.split(".")) if pre else (), + raw=raw, + ) + + +def _compare_prerelease(a: tuple[str, ...], b: tuple[str, ...]) -> int: + if not a and not b: + return 0 + if not a: + return 1 # release outranks prerelease + if not b: + return -1 + for ai, bi in zip(a, b, strict=False): + if ai == bi: + continue + an, bn = ai.isdigit(), bi.isdigit() + if an and bn: + return int(ai) - int(bi) + if an: + return -1 + if bn: + return 1 + return -1 if ai < bi else 1 + return len(a) - len(b) + + +def compare_versions(a: SemVer, b: SemVer) -> int: + if a.major != b.major: + return a.major - b.major + if a.minor != b.minor: + return a.minor - b.minor + if a.patch != b.patch: + return a.patch - b.patch + return _compare_prerelease(a.prerelease, b.prerelease) + + +def _core_equals(v: SemVer, c: ParsedConstraint) -> bool: + return v.major == c.major and v.minor == c.minor and v.patch == c.patch + + +def satisfies(v: SemVer, c: ParsedConstraint) -> bool: + constraint_core = SemVer( + major=c.major, + minor=c.minor, + patch=c.patch, + prerelease=tuple(c.prerelease.split(".")) if c.prerelease else (), + raw=c.raw, + ) + + if c.op == "exact": + return compare_versions(v, constraint_core) == 0 + + if compare_versions(v, constraint_core) < 0: + return False + + # Exclude prereleases from range matches unless the constraint pins the same + # core version and is itself a prerelease. + if v.prerelease and not (_core_equals(v, c) and constraint_core.prerelease): + return False + + if c.op == "caret": + if c.major > 0: + return v.major == c.major + if c.minor > 0: + return v.major == 0 and v.minor == c.minor + return v.major == 0 and v.minor == 0 and v.patch == c.patch + + # tilde: same major.minor + return v.major == c.major and v.minor == c.minor + + +def select_highest(candidates: list[str], constraint: ParsedConstraint) -> str | None: + best: SemVer | None = None + for raw in candidates: + v = parse_version(raw) + if v is None or not satisfies(v, constraint): + continue + if best is None or compare_versions(v, best) > 0: + best = v + return best.raw if best else None diff --git a/agent/src/workflow/validator.py b/agent/src/workflow/validator.py index 25f2a6eeb..884e7a870 100644 --- a/agent/src/workflow/validator.py +++ b/agent/src/workflow/validator.py @@ -46,8 +46,18 @@ # Built-in (Phase 1-3) policy modules / MCP servers. Registry refs (registry://) # are accepted syntactically now and resolved against #246 in Phase 4 (rule 8). +# +# This is a LENIENT acceptance check for the workflow validator only — it admits +# both the legacy 2-segment illustrative form (``registry://prompt/name``, still +# used by the workflow-validation corpus) and the strict #246 3-segment form +# (``registry://mcp_server/ns/name@^1.4.1``). The authoritative #246 grammar is +# ``registry.ref.parse_ref`` (mirrored in registry/ref.ts); resolution enforces +# the strict form. The kind segment gains ``_`` (snake_case kinds) and an optional +# ``@`` suffix so a valid strict ref never fails this check. _BUILTIN_REF = re.compile(r"^builtin/[a-z][a-z0-9_]*$") -_REGISTRY_REF = re.compile(r"^registry://[a-z][a-z0-9-]*/[a-z0-9][a-z0-9./-]*$") +_REGISTRY_REF = re.compile( + r"^registry://[a-z][a-z0-9_-]*/[a-z0-9][a-z0-9./-]*(?:/[a-z0-9][a-z0-9._-]*)?(?:@[\^~]?\S+)?$" +) # Mutating built-in tools — forbidden under the read-only tier (rule 6) and when # read_only:true (rule 4, shape half is in the schema). diff --git a/agent/tests/test_registry_agentcore_client.py b/agent/tests/test_registry_agentcore_client.py new file mode 100644 index 000000000..6ecd48c26 --- /dev/null +++ b/agent/tests/test_registry_agentcore_client.py @@ -0,0 +1,67 @@ +"""Unit tests for registry.agentcore_client read-side extraction (#246). + +Focus: ``_extract_runtime`` must recover the runtime payload from all three +descriptor storage shapes — CUSTOM (verbatim JSON), MCP (JSON + ``_meta``), and +AGENT_SKILLS (Markdown frontmatter, runtime in ``x-abca-runtime``). The +AGENT_SKILLS parse must byte-match what the TS adapter writes (parity). +""" + +from __future__ import annotations + +import json + +from registry.agentcore_client import AgentCoreRegistryClient + +_RUNTIME_META_KEY = "dev.abca.runtime" + + +def _client() -> AgentCoreRegistryClient: + return AgentCoreRegistryClient("r", None) + + +class TestExtractRuntime: + def test_custom_reads_body_runtime(self): + runtime = {"cedar_text": "forbid(principal, action, resource);"} + raw = { + "descriptorType": "CUSTOM", + "descriptors": { + "custom": {"inlineContent": json.dumps({"runtime": runtime, "discovery": {}})} + }, + } + assert _client()._extract_runtime(raw) == runtime + + def test_mcp_reads_meta_block(self): + runtime = {"type": "http", "url": "https://mcp.example.com/mcp"} + server = {"name": "acme/x", "version": "1.0.0", "_meta": {_RUNTIME_META_KEY: runtime}} + raw = { + "descriptorType": "MCP", + "descriptors": {"mcp": {"server": {"inlineContent": json.dumps(server)}}}, + } + assert _client()._extract_runtime(raw) == runtime + + def test_agent_skills_parses_frontmatter(self): + # Exactly the shape the TS adapter's buildSkillMd emits. + runtime = {"prompt_fragment": "Add a note.", "tool_hints": ["Edit"]} + skill_md = ( + "---\n" + "name: acme-readme-helper\n" + "description: d\n" + "version: 1.0.0\n" + f"x-abca-runtime: '{json.dumps(runtime)}'\n" + "---\n" + "# acme/readme-helper\n" + "body" + ) + raw = { + "descriptorType": "AGENT_SKILLS", + "descriptors": {"agentSkills": {"skillMd": {"inlineContent": skill_md}}}, + } + assert _client()._extract_runtime(raw) == runtime + + def test_agent_skills_missing_frontmatter_key_returns_empty(self): + skill_md = "---\nname: x\ndescription: d\nversion: 1.0.0\n---\nbody" + raw = { + "descriptorType": "AGENT_SKILLS", + "descriptors": {"agentSkills": {"skillMd": {"inlineContent": skill_md}}}, + } + assert _client()._extract_runtime(raw) == {} diff --git a/agent/tests/test_registry_resolution_corpus.py b/agent/tests/test_registry_resolution_corpus.py new file mode 100644 index 000000000..7012297c5 --- /dev/null +++ b/agent/tests/test_registry_resolution_corpus.py @@ -0,0 +1,69 @@ +"""Grammar parity corpus runner (Python side) for registry:// refs (#246). + +Loads ``contracts/registry-resolution/cases.json`` and asserts the Python parser +(``registry.ref.parse_ref``) agrees with each golden verdict. The TypeScript +runner (``cdk/test/handlers/shared/registry-resolution-parity.test.ts``) runs the +same file against ``parseRef``; both must agree, so the two grammars cannot drift. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from registry.ref import RefError, parse_ref + +_CASES_FILE = ( + Path(os.path.dirname(__file__)) + / ".." + / ".." + / "contracts" + / "registry-resolution" + / "cases.json" +).resolve() + + +def _load_cases() -> list[dict]: + assert _CASES_FILE.is_file(), ( + f"expected corpus at {_CASES_FILE}; see contracts/registry-resolution/README.md" + ) + data = json.loads(_CASES_FILE.read_text(encoding="utf-8")) + cases = data["cases"] + assert cases, "corpus has no cases; at least one is required" + return cases + + +_CASES = _load_cases() + + +@pytest.mark.parametrize("case", _CASES, ids=[c["name"] for c in _CASES]) +def test_parse_matches_fixture(case: dict) -> None: + ref = case["ref"] + expected = case["expected"] + + if not expected["ok"]: + with pytest.raises(RefError) as exc: + parse_ref(ref) + assert exc.value.reason == expected["reason"], ( + f"{case['name']}: reason drift — got {exc.value.reason!r}, " + f"expected {expected['reason']!r}" + ) + return + + parsed = parse_ref(ref) + assert parsed.kind == expected["kind"] + assert parsed.namespace == expected["namespace"] + assert parsed.name == expected["name"] + assert parsed.constraint.op == expected["op"] + assert parsed.constraint.major == expected["major"] + assert parsed.constraint.minor == expected["minor"] + assert parsed.constraint.patch == expected["patch"] + assert parsed.constraint.prerelease == expected["prerelease"] + + +def test_corpus_present_and_nonempty() -> None: + assert _CASES_FILE.is_file() + assert len(_CASES) >= 1 diff --git a/cdk/bootstrap/BOOTSTRAP_HASH b/cdk/bootstrap/BOOTSTRAP_HASH index bc08e5ced..065405ab2 100644 --- a/cdk/bootstrap/BOOTSTRAP_HASH +++ b/cdk/bootstrap/BOOTSTRAP_HASH @@ -1 +1 @@ -b0501c8a57f20e4b5bf50d6fe8bbb934310adc4b64480228565d8392a19d1503 +29f5882221e3d69e107d985eb63c7ad1c239361c02adebadd57f2d32e401b076 diff --git a/cdk/bootstrap/bootstrap-template.yaml b/cdk/bootstrap/bootstrap-template.yaml index 8026647ce..45320bbe1 100644 --- a/cdk/bootstrap/bootstrap-template.yaml +++ b/cdk/bootstrap/bootstrap-template.yaml @@ -1,7 +1,7 @@ # GENERATED FILE - DO NOT EDIT DIRECTLY # This template is generated by: npx tsx scripts/generate-bootstrap-template.ts # ABCA Bootstrap Policy Version: 1.2.0 -# ABCA Bootstrap Policy Hash: b0501c8a57f20e4b5bf50d6fe8bbb934310adc4b64480228565d8392a19d1503 +# ABCA Bootstrap Policy Hash: 29f5882221e3d69e107d985eb63c7ad1c239361c02adebadd57f2d32e401b076 # # Based on the default CDK bootstrap template with the following modifications: # - BootstrapVariant set to "ABCA: Least-Privilege Bootstrap" @@ -814,6 +814,7 @@ Resources: Effect: Allow Resource: - arn:aws:cloudformation:*:*:stack/backgroundagent-dev/* + - arn:aws:cloudformation:*:*:stack/backgroundagent-dev-* - arn:aws:cloudformation:*:*:stack/CDKToolkit/* Sid: CloudFormationSelf - Action: @@ -860,6 +861,7 @@ Resources: - bedrock.amazonaws.com - bedrock-agentcore.amazonaws.com - events.amazonaws.com + - states.amazonaws.com - vpc-flow-logs.amazonaws.com Effect: Allow Resource: arn:aws:iam::*:role/backgroundagent-dev-* @@ -1054,6 +1056,10 @@ Resources: - cognito-idp:DeleteUserPoolClient - cognito-idp:DescribeUserPoolClient - cognito-idp:UpdateUserPoolClient + - cognito-idp:CreateGroup + - cognito-idp:DeleteGroup + - cognito-idp:GetGroup + - cognito-idp:UpdateGroup - cognito-idp:TagResource - cognito-idp:UntagResource - cognito-idp:ListTagsForResource @@ -1102,6 +1108,17 @@ Resources: Effect: Allow Resource: arn:aws:sqs:*:*:backgroundagent-dev-* Sid: SQS + - Action: + - states:CreateStateMachine + - states:DeleteStateMachine + - states:DescribeStateMachine + - states:UpdateStateMachine + - states:TagResource + - states:UntagResource + - states:ListTagsForResource + Effect: Allow + Resource: arn:aws:states:*:*:stateMachine:backgroundagent-dev-* + Sid: StepFunctions - Action: - cloudfront:CreateDistribution - cloudfront:UpdateDistribution @@ -1373,7 +1390,7 @@ Outputs: Value: 1.2.0 BootstrapPolicyHash: Description: SHA-256 hash of the ABCA bootstrap policy bundle for drift detection - Value: b0501c8a57f20e4b5bf50d6fe8bbb934310adc4b64480228565d8392a19d1503 + Value: 29f5882221e3d69e107d985eb63c7ad1c239361c02adebadd57f2d32e401b076 BootstrapPolicySet: Description: Comma-separated list of active ABCA bootstrap policy names Value: diff --git a/cdk/bootstrap/policies/application.json b/cdk/bootstrap/policies/application.json index 9f5932d89..f6b06d007 100644 --- a/cdk/bootstrap/policies/application.json +++ b/cdk/bootstrap/policies/application.json @@ -111,6 +111,10 @@ "cognito-idp:DeleteUserPoolClient", "cognito-idp:DescribeUserPoolClient", "cognito-idp:UpdateUserPoolClient", + "cognito-idp:CreateGroup", + "cognito-idp:DeleteGroup", + "cognito-idp:GetGroup", + "cognito-idp:UpdateGroup", "cognito-idp:TagResource", "cognito-idp:UntagResource", "cognito-idp:ListTagsForResource", @@ -171,6 +175,20 @@ "Resource": "arn:aws:sqs:*:*:backgroundagent-dev-*", "Sid": "SQS" }, + { + "Action": [ + "states:CreateStateMachine", + "states:DeleteStateMachine", + "states:DescribeStateMachine", + "states:UpdateStateMachine", + "states:TagResource", + "states:UntagResource", + "states:ListTagsForResource" + ], + "Effect": "Allow", + "Resource": "arn:aws:states:*:*:stateMachine:backgroundagent-dev-*", + "Sid": "StepFunctions" + }, { "Action": [ "cloudfront:CreateDistribution", diff --git a/cdk/bootstrap/policies/infrastructure.json b/cdk/bootstrap/policies/infrastructure.json index 916f38ab9..5ead81191 100644 --- a/cdk/bootstrap/policies/infrastructure.json +++ b/cdk/bootstrap/policies/infrastructure.json @@ -22,6 +22,7 @@ "Effect": "Allow", "Resource": [ "arn:aws:cloudformation:*:*:stack/backgroundagent-dev/*", + "arn:aws:cloudformation:*:*:stack/backgroundagent-dev-*", "arn:aws:cloudformation:*:*:stack/CDKToolkit/*" ], "Sid": "CloudFormationSelf" @@ -75,6 +76,7 @@ "bedrock.amazonaws.com", "bedrock-agentcore.amazonaws.com", "events.amazonaws.com", + "states.amazonaws.com", "vpc-flow-logs.amazonaws.com" ] } diff --git a/cdk/package.json b/cdk/package.json index 38dc196be..dc862f38b 100644 --- a/cdk/package.json +++ b/cdk/package.json @@ -17,6 +17,7 @@ "@aws-cdk/aws-bedrock-alpha": "2.260.0-alpha.0", "@aws-crypto/sha256-js": "^5.2.0", "@aws-sdk/client-bedrock-agentcore": "^3.1078.0", + "@aws-sdk/client-bedrock-agentcore-control": "^3.1078.0", "@aws-sdk/client-bedrock-runtime": "^3.1078.0", "@aws-sdk/client-dynamodb": "^3.1078.0", "@aws-sdk/client-ecs": "^3.1078.0", diff --git a/cdk/src/bootstrap/policies/application.ts b/cdk/src/bootstrap/policies/application.ts index 88b668628..4ab273d34 100644 --- a/cdk/src/bootstrap/policies/application.ts +++ b/cdk/src/bootstrap/policies/application.ts @@ -152,6 +152,11 @@ export function applicationPolicy(): iam.PolicyDocument { 'cognito-idp:DeleteUserPoolClient', 'cognito-idp:DescribeUserPoolClient', 'cognito-idp:UpdateUserPoolClient', + // User pool groups for registry publish/approve gating (#246). + 'cognito-idp:CreateGroup', + 'cognito-idp:DeleteGroup', + 'cognito-idp:GetGroup', + 'cognito-idp:UpdateGroup', 'cognito-idp:TagResource', 'cognito-idp:UntagResource', 'cognito-idp:ListTagsForResource', @@ -214,6 +219,23 @@ export function applicationPolicy(): iam.PolicyDocument { resources: ['arn:aws:sqs:*:*:backgroundagent-dev-*'], }), + new iam.PolicyStatement({ + // The CDK Provider framework (AgentCore registry provisioning, #246) + // creates a Step Functions state machine as its async completion waiter. + sid: 'StepFunctions', + effect: iam.Effect.ALLOW, + actions: [ + 'states:CreateStateMachine', + 'states:DeleteStateMachine', + 'states:DescribeStateMachine', + 'states:UpdateStateMachine', + 'states:TagResource', + 'states:UntagResource', + 'states:ListTagsForResource', + ], + resources: ['arn:aws:states:*:*:stateMachine:backgroundagent-dev-*'], + }), + new iam.PolicyStatement({ sid: 'CloudFront', effect: iam.Effect.ALLOW, diff --git a/cdk/src/bootstrap/policies/infrastructure.ts b/cdk/src/bootstrap/policies/infrastructure.ts index 38612b505..3bf0a20fc 100644 --- a/cdk/src/bootstrap/policies/infrastructure.ts +++ b/cdk/src/bootstrap/policies/infrastructure.ts @@ -54,6 +54,9 @@ export function infrastructurePolicy(): iam.PolicyDocument { ], resources: [ 'arn:aws:cloudformation:*:*:stack/backgroundagent-dev/*', + // Nested stacks (e.g. the AgentCore registry, #246) synth as child + // stacks named ``backgroundagent-dev-``. + 'arn:aws:cloudformation:*:*:stack/backgroundagent-dev-*', 'arn:aws:cloudformation:*:*:stack/CDKToolkit/*', ], }), @@ -111,6 +114,7 @@ export function infrastructurePolicy(): iam.PolicyDocument { 'bedrock.amazonaws.com', 'bedrock-agentcore.amazonaws.com', 'events.amazonaws.com', + 'states.amazonaws.com', 'vpc-flow-logs.amazonaws.com', ], }, diff --git a/cdk/src/bootstrap/resource-action-map.ts b/cdk/src/bootstrap/resource-action-map.ts index f27394961..957116f01 100644 --- a/cdk/src/bootstrap/resource-action-map.ts +++ b/cdk/src/bootstrap/resource-action-map.ts @@ -59,10 +59,14 @@ export const RESOURCE_ACTION_MAP: Record = { 'AWS::BedrockAgentCore::Runtime': ['bedrock-agentcore:CreateRuntime'], 'AWS::CloudFront::Distribution': ['cloudfront:CreateDistribution'], 'AWS::CloudFront::OriginAccessControl': ['cloudfront:CreateOriginAccessControl'], + // NestedStack for the AgentCore registry (#246) — CFN creates a child stack. + 'AWS::CloudFormation::Stack': ['cloudformation:CreateStack'], 'AWS::CloudWatch::Alarm': ['cloudwatch:PutMetricAlarm'], 'AWS::CloudWatch::Dashboard': ['cloudwatch:PutDashboard'], 'AWS::Cognito::UserPool': ['cognito-idp:CreateUserPool'], 'AWS::Cognito::UserPoolClient': ['cognito-idp:CreateUserPoolClient'], + // RegistryPublisher / RegistryApprover groups (#246). + 'AWS::Cognito::UserPoolGroup': ['cognito-idp:CreateGroup'], 'AWS::DynamoDB::Table': ['dynamodb:CreateTable'], 'AWS::EC2::EIP': ['ec2:AllocateAddress'], 'AWS::EC2::FlowLog': ['ec2:CreateFlowLogs'], @@ -93,9 +97,13 @@ export const RESOURCE_ACTION_MAP: Record = { 'AWS::S3::Bucket': ['s3:CreateBucket'], 'AWS::SecretsManager::Secret': ['secretsmanager:CreateSecret'], 'AWS::SQS::Queue': ['sqs:CreateQueue'], + // The AgentCore registry provisioning custom resource uses the CDK Provider + // framework, whose async waiter is a Step Functions state machine (#246). + 'AWS::StepFunctions::StateMachine': ['states:CreateStateMachine'], 'AWS::WAFv2::WebACL': ['wafv2:CreateWebACL'], 'AWS::WAFv2::WebACLAssociation': ['wafv2:AssociateWebACL'], 'Custom::AWS': ['lambda:InvokeFunction'], + 'Custom::AgentCoreRegistry': ['lambda:InvokeFunction'], 'Custom::S3AutoDeleteObjects': ['lambda:InvokeFunction'], 'Custom::VpcRestrictDefaultSG': ['lambda:InvokeFunction'], }; diff --git a/cdk/src/constructs/registry.ts b/cdk/src/constructs/registry.ts new file mode 100644 index 000000000..2b470334e --- /dev/null +++ b/cdk/src/constructs/registry.ts @@ -0,0 +1,221 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// Provisions the AgentCore registry that backs the agent asset registry (#246). +// +// CreateRegistry is asynchronous (CREATING -> READY, ~70s observed) and there is +// no CDK L1/L2 construct for it during preview, so this wraps the CDK Provider +// framework: an `onEvent` Lambda starts the mutation and an `isComplete` Lambda is +// polled until the registry reaches a stable state. +// +// GA-THROWAWAY: replace this whole construct with the native AgentCore CDK +// construct once it ships (~2026-08-06). Everything downstream talks to the +// registry through the `RegistryClient` seam, so this swap is self-contained. +import * as path from 'path'; +import { CustomResource, Duration, NestedStack, type NestedStackProps, Stack } from 'aws-cdk-lib'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import { Architecture, Runtime } from 'aws-cdk-lib/aws-lambda'; +import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'; +import * as cr from 'aws-cdk-lib/custom-resources'; +import { NagSuppressions } from 'cdk-nag'; +import { Construct } from 'constructs'; + +const PROVISION_TIMEOUT_SECONDS = 60; +const PROVISION_MEMORY_MB = 256; +// Registry create observed at ~70s; poll generously and cap the total wait. +const POLL_INTERVAL_SECONDS = 10; +const TOTAL_TIMEOUT_MINUTES = 15; +const POLL_INTERVAL = Duration.seconds(POLL_INTERVAL_SECONDS); +const TOTAL_TIMEOUT = Duration.minutes(TOTAL_TIMEOUT_MINUTES); + +export interface AgentRegistryProps { + /** Registry name — unique per account, alphanumerics + underscores. */ + readonly registryName: string; + /** Optional human description stored on the registry. */ + readonly description?: string; +} + +/** + * The AgentCore registry resource. Exposes {@link registryId} / {@link registryArn} + * for handlers and the `RegistryClient` adapter to target. + */ +export class AgentRegistry extends Construct { + public readonly registryId: string; + public readonly registryArn: string; + + constructor(scope: Construct, id: string, props: AgentRegistryProps) { + super(scope, id); + + const entry = path.join(__dirname, '..', 'handlers', 'registry-provisioning', 'index.ts'); + // The AgentCore control-plane SDK is preview and NOT in the Lambda runtime, so + // it must be bundled (the repo default externalizes @aws-sdk/*, which we override). + const bundling = { externalModules: [] as string[] }; + + const commonFnProps = { + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(PROVISION_TIMEOUT_SECONDS), + memorySize: PROVISION_MEMORY_MB, + bundling, + }; + + const onEventFn = new lambda.NodejsFunction(this, 'OnEventFn', { + ...commonFnProps, + entry, + handler: 'onEvent', + }); + const isCompleteFn = new lambda.NodejsFunction(this, 'IsCompleteFn', { + ...commonFnProps, + entry, + handler: 'isComplete', + }); + + // Account-level actions authorized against the account ARN (`:*`), NOT a + // `registry/{id}` ARN — at call time the target resource does not exist yet. + // Scoping these to `registry/*` fails with AccessDenied (observed on deploy). + // - CreateRegistry/ListRegistries: no registry exists at create time. + // - CreateRegistry ALSO provisions a workload identity under the hood, so + // the role needs the WorkloadIdentity create/get/delete actions too — + // the registry lands in CREATE_FAILED ("Unable to create workload + // identity because access was denied") without them. + const createPolicy = new iam.PolicyStatement({ + actions: [ + 'bedrock-agentcore:CreateRegistry', + 'bedrock-agentcore:ListRegistries', + 'bedrock-agentcore:CreateWorkloadIdentity', + 'bedrock-agentcore:GetWorkloadIdentity', + 'bedrock-agentcore:DeleteWorkloadIdentity', + ], + resources: ['*'], + }); + const registryPolicy = new iam.PolicyStatement({ + actions: [ + 'bedrock-agentcore:GetRegistry', + 'bedrock-agentcore:DeleteRegistry', + 'bedrock-agentcore:ListRegistryRecords', + 'bedrock-agentcore:DeleteRegistryRecord', + ], + resources: [ + Stack.of(this).formatArn({ + service: 'bedrock-agentcore', + resource: 'registry', + resourceName: '*', + }), + Stack.of(this).formatArn({ + service: 'bedrock-agentcore', + resource: 'registry', + resourceName: '*/record/*', + }), + ], + }); + for (const fn of [onEventFn, isCompleteFn]) { + fn.addToRolePolicy(createPolicy); + fn.addToRolePolicy(registryPolicy); + } + + const provider = new cr.Provider(this, 'Provider', { + onEventHandler: onEventFn, + isCompleteHandler: isCompleteFn, + queryInterval: POLL_INTERVAL, + totalTimeout: TOTAL_TIMEOUT, + }); + + const resource = new CustomResource(this, 'Resource', { + serviceToken: provider.serviceToken, + resourceType: 'Custom::AgentCoreRegistry', + properties: { + RegistryName: props.registryName, + Description: props.description ?? '', + }, + }); + + this.registryId = resource.getAttString('RegistryId'); + this.registryArn = resource.getAttString('RegistryArn'); + + NagSuppressions.addResourceSuppressions( + [onEventFn, isCompleteFn], + [ + { + id: 'AwsSolutions-IAM4', + reason: 'AWSLambdaBasicExecutionRole is required for CloudWatch Logs access', + }, + { + id: 'AwsSolutions-IAM5', + reason: + 'CreateRegistry/ListRegistries are account-level actions authorized against ' + + '`*` (no registry exists yet at create time). GetRegistry/DeleteRegistry + ' + + 'record actions use registry/* and registry/*/record/* wildcards because the ' + + 'registry id and record ids are server-assigned and unknown at synth.', + }, + ], + true, + ); + + // The CDK Provider framework synthesizes its own waiter state machine and + // framework Lambdas (onEvent/isComplete/onTimeout) that we do not author. + // These findings are on framework-managed resources; GA-throwaway anyway. + NagSuppressions.addResourceSuppressions( + provider, + [ + { + id: 'AwsSolutions-SF1', + reason: 'Provider-framework waiter state machine; logging config is managed by the CDK custom-resources framework', + }, + { + id: 'AwsSolutions-SF2', + reason: 'Provider-framework waiter state machine; X-Ray config is managed by the CDK custom-resources framework', + }, + { + id: 'AwsSolutions-IAM4', + reason: 'Provider-framework Lambdas use AWS managed AWSLambdaBasicExecutionRole — required by the CDK custom-resources framework', + }, + { + id: 'AwsSolutions-IAM5', + reason: 'Provider-framework grants InvokeFunction on the user handler versions (Arn:*) — generated by the CDK custom-resources framework', + }, + ], + true, + ); + } +} + +/** + * NestedStack wrapper for {@link AgentRegistry} (#246). + * + * The registry + its Provider framework (custom-resource Lambdas, IAM roles, + * Step Functions waiter) contribute ~20 resources. Nesting them keeps the root + * ``AgentStack`` under CloudFormation's hard 500-resource-per-stack limit — the + * nested stack gets its own budget. ``registryId``/``registryArn`` are surfaced + * so the parent can thread them into the orchestrator + TaskApi exactly as + * before (CDK auto-wires the cross-stack export/import). + */ +export class AgentRegistryStack extends NestedStack { + public readonly registryId: string; + public readonly registryArn: string; + + constructor(scope: Construct, id: string, props: AgentRegistryProps & NestedStackProps) { + super(scope, id, props); + const registry = new AgentRegistry(this, 'AgentRegistry', { + registryName: props.registryName, + description: props.description, + }); + this.registryId = registry.registryId; + this.registryArn = registry.registryArn; + } +} diff --git a/cdk/src/constructs/task-api.ts b/cdk/src/constructs/task-api.ts index 305285315..b02ccd7b0 100644 --- a/cdk/src/constructs/task-api.ts +++ b/cdk/src/constructs/task-api.ts @@ -200,6 +200,13 @@ export interface TaskApiProps { * Required when attachmentsBucket is provided. */ readonly userConcurrencyTable?: dynamodb.ITable; + + /** + * AgentCore registry id backing the agent asset registry (#246). When set, + * the registry publish/resolve/list/show routes are wired and the handlers + * receive it via `AGENT_REGISTRY_ID`. + */ + readonly agentRegistryId?: string; } /** @@ -1287,6 +1294,94 @@ export class TaskApi extends Construct { allFunctions.push(createWebhookFn, listWebhooksFn, deleteWebhookFn, webhookAuthorizerFn, webhookCreateTaskFn); } + // --- Agent asset registry endpoints (#246, only when a registry is wired) --- + if (props.agentRegistryId) { + // Two Cognito groups gate writes (REGISTRY.md §10): publishers submit, + // approvers drive records to APPROVED. Resolve/list/show are open to any + // authenticated caller. + new cognito.CfnUserPoolGroup(this, 'RegistryPublisherGroup', { + userPoolId: this.userPool.userPoolId, + groupName: 'RegistryPublisher', + description: 'May publish agent asset registry records (#246).', + }); + new cognito.CfnUserPoolGroup(this, 'RegistryApproverGroup', { + userPoolId: this.userPool.userPoolId, + groupName: 'RegistryApprover', + description: 'May approve/reject/deprecate registry records and auto-approve on publish (#246).', + }); + + const registryEnv = { ...commonEnv, AGENT_REGISTRY_ID: props.agentRegistryId }; + // The AgentCore control-plane SDK is preview and NOT in the Lambda runtime, + // so bundle it (do not externalize) — mirrors the provisioning handler. + const registryBundling: lambda.BundlingOptions = { + externalModules: (commonBundling.externalModules ?? []).filter( + (m) => m !== '@aws-sdk/client-bedrock-agentcore-control', + ), + }; + const registryFn = (fnId: string, entry: string): lambda.NodejsFunction => + new lambda.NodejsFunction(this, fnId, { + entry: path.join(handlersDir, entry), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + environment: registryEnv, + bundling: registryBundling, + timeout: Duration.seconds(API_HANDLER_TIMEOUT_SECONDS), + }); + + const registryPublishFn = registryFn('RegistryPublishFn', 'registry-publish.ts'); + const registryResolveFn = registryFn('RegistryResolveFn', 'registry-resolve.ts'); + const registryListFn = registryFn('RegistryListFn', 'registry-list.ts'); + const registryShowFn = registryFn('RegistryShowFn', 'registry-show.ts'); + const registryFns = [registryPublishFn, registryResolveFn, registryListFn, registryShowFn]; + + // Control-plane + data-plane actions, scoped to this account's registries. + const registryArn = Stack.of(this).formatArn({ + service: 'bedrock-agentcore', + resource: 'registry', + resourceName: '*', + arnFormat: ArnFormat.SLASH_RESOURCE_NAME, + }); + const recordArn = Stack.of(this).formatArn({ + service: 'bedrock-agentcore', + resource: 'registry', + resourceName: '*/record/*', + arnFormat: ArnFormat.SLASH_RESOURCE_NAME, + }); + const readActions = [ + 'bedrock-agentcore:GetRegistryRecord', + 'bedrock-agentcore:ListRegistryRecords', + ]; + const writeActions = [ + 'bedrock-agentcore:CreateRegistryRecord', + 'bedrock-agentcore:SubmitRegistryRecordForApproval', + 'bedrock-agentcore:UpdateRegistryRecordStatus', + ]; + registryPublishFn.addToRolePolicy( + new iam.PolicyStatement({ actions: [...readActions, ...writeActions], resources: [registryArn, recordArn] }), + ); + for (const fn of [registryResolveFn, registryListFn, registryShowFn]) { + fn.addToRolePolicy(new iam.PolicyStatement({ actions: readActions, resources: [registryArn, recordArn] })); + } + + // --- Routes: /registry --- + const registry = this.api.root.addResource('registry'); + const records = registry.addResource('records'); + records.addMethod('POST', new apigw.LambdaIntegration(registryPublishFn), cognitoAuthOptions); + records.addMethod('GET', new apigw.LambdaIntegration(registryListFn), cognitoAuthOptions); + + const resolve = registry.addResource('resolve'); + resolve.addMethod('GET', new apigw.LambdaIntegration(registryResolveFn), cognitoAuthOptions); + + // show: /registry/records/{kind}/{namespace}/{name} + const byKind = records.addResource('{kind}'); + const byNamespace = byKind.addResource('{namespace}'); + const byName = byNamespace.addResource('{name}'); + byName.addMethod('GET', new apigw.LambdaIntegration(registryShowFn), cognitoAuthOptions); + + allFunctions.push(...registryFns); + } + // --- cdk-nag suppressions for CDK-generated IAM policies --- for (const fn of allFunctions) { NagSuppressions.addResourceSuppressions(fn, [ @@ -1296,7 +1391,7 @@ export class TaskApi extends Construct { }, { id: 'AwsSolutions-IAM5', - reason: 'DynamoDB index/* wildcards generated by CDK grantReadWriteData/grantReadData for GSI access', + reason: 'DynamoDB index/* wildcards generated by CDK grantReadWriteData/grantReadData for GSI access; bedrock-agentcore registry/* + registry/*/record/* wildcards because record ids are server-assigned and unknown at synth (#246)', }, ], true); } diff --git a/cdk/src/handlers/registry-list.ts b/cdk/src/handlers/registry-list.ts new file mode 100644 index 000000000..6337e02a1 --- /dev/null +++ b/cdk/src/handlers/registry-list.ts @@ -0,0 +1,79 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { ulid } from 'ulid'; +import { extractUserId } from './shared/gateway'; +import { logger } from './shared/logger'; +import { makeRegistryClient } from './shared/registry/factory'; +import { compareVersions, parseVersion } from './shared/registry/resolver'; +import type { RegistryRecord } from './shared/registry/types'; +import { ErrorCode, errorResponse, successResponse } from './shared/response'; +import type { RegistryListEntry } from './shared/types'; + +/** + * GET /v1/registry/records?kind=&namespace= — list assets (grouped by + * kind/namespace/name, one row per asset with its latest version). + * Excludes tombstoned/never-approved noise by reporting the latest version + * present regardless of status (status column tells the caller the rest). + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + const requestId = ulid(); + try { + const userId = extractUserId(event); + if (!userId) { + return errorResponse(401, ErrorCode.UNAUTHORIZED, 'Missing or invalid authentication.', requestId); + } + + const kind = event.queryStringParameters?.kind; + const namespace = event.queryStringParameters?.namespace; + + const client = makeRegistryClient(); + const records = await client.listRecords({ kind, namespace }); + + const entries = groupLatest(records); + return successResponse(200, { assets: entries }, requestId); + } catch (err) { + logger.error('registry list failed', { requestId, error: String(err) }); + return errorResponse(500, ErrorCode.INTERNAL_ERROR, 'Failed to list records.', requestId); + } +} + +/** Collapse per-version records into one entry per asset at its highest version. */ +function groupLatest(records: readonly RegistryRecord[]): RegistryListEntry[] { + const byAsset = new Map(); + for (const r of records) { + const key = `${r.kind}/${r.namespace}/${r.name}`; + const current = byAsset.get(key); + if (!current) { + byAsset.set(key, r); + continue; + } + const a = parseVersion(r.version); + const b = parseVersion(current.version); + if (a && b && compareVersions(a, b) > 0) byAsset.set(key, r); + } + return [...byAsset.values()].map((r) => ({ + kind: r.kind, + namespace: r.namespace, + name: r.name, + latest_version: r.version || null, + status: r.status, + })); +} diff --git a/cdk/src/handlers/registry-provisioning/index.ts b/cdk/src/handlers/registry-provisioning/index.ts new file mode 100644 index 000000000..2b414fb12 --- /dev/null +++ b/cdk/src/handlers/registry-provisioning/index.ts @@ -0,0 +1,163 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// Custom-resource handlers that provision the AgentCore registry that backs the +// agent asset registry (#246). CreateRegistry is asynchronous (CREATING -> READY, +// ~70s observed), so this uses the CDK Provider framework: `onEvent` kicks off the +// mutation and `isComplete` is polled until the registry reaches a stable state. +// +// GA-THROWAWAY: swap this for the native AgentCore CDK L1/L2 construct once it +// ships (~2026-08-06). The `RegistryClient` seam keeps that swap confined. +import { + BedrockAgentCoreControlClient, + CreateRegistryCommand, + GetRegistryCommand, + DeleteRegistryCommand, + ListRegistryRecordsCommand, + DeleteRegistryRecordCommand, + ConflictException, + ResourceNotFoundException, +} from '@aws-sdk/client-bedrock-agentcore-control'; +import { logger } from '../shared/logger'; + +// The Provider framework's request/response shapes are not exported from +// aws-cdk-lib's public entrypoints, so we model the fields we use. +interface OnEventRequest { + readonly RequestType: 'Create' | 'Update' | 'Delete'; + readonly PhysicalResourceId?: string; + readonly ResourceProperties: { readonly RegistryName: string; readonly Description?: string }; +} +interface OnEventResponse { + readonly PhysicalResourceId?: string; + readonly Data?: Record; +} +interface IsCompleteRequest extends OnEventRequest { + readonly PhysicalResourceId: string; +} +interface IsCompleteResponse { + readonly IsComplete: boolean; + readonly Data?: Record; +} + +const client = new BedrockAgentCoreControlClient({}); + +/** The registry id is the last ARN segment; we also accept a bare id. */ +function registryIdFromArn(arn: string): string { + return arn.includes('/') ? arn.split('/').pop()! : arn; +} + +export async function onEvent(event: OnEventRequest): Promise { + logger.info('registry-provisioning onEvent', { requestType: event.RequestType }); + switch (event.RequestType) { + case 'Create': { + const { RegistryName, Description } = event.ResourceProperties; + const res = await client.send( + new CreateRegistryCommand({ name: RegistryName, description: Description }), + ); + const registryId = registryIdFromArn(res.registryArn!); + // PhysicalResourceId drives isComplete + delete; carry the id there. + return { PhysicalResourceId: registryId, Data: { RegistryId: registryId, RegistryArn: res.registryArn! } }; + } + case 'Update': { + // The registry name is immutable in this design; a name change would force + // replacement (new PhysicalResourceId) via CreateRegistry on the new value. + // Nothing to mutate in place, so echo the existing id back. + return { PhysicalResourceId: event.PhysicalResourceId }; + } + case 'Delete': { + const registryId = event.PhysicalResourceId!; + // If Create never succeeded the id is a CFN-generated token, not a real + // registry — GetRegistry will 404 and isComplete short-circuits. + await drainRecords(registryId); + try { + await client.send(new DeleteRegistryCommand({ registryId })); + } catch (err) { + if (err instanceof ResourceNotFoundException) { + return { PhysicalResourceId: registryId }; + } + // Records may still be settling; isComplete will retry the delete. + if (!(err instanceof ConflictException)) throw err; + } + return { PhysicalResourceId: registryId }; + } + } +} + +export async function isComplete(event: IsCompleteRequest): Promise { + const registryId = event.PhysicalResourceId; + if (event.RequestType === 'Delete') { + try { + await client.send(new GetRegistryCommand({ registryId })); + } catch (err) { + if (err instanceof ResourceNotFoundException) return { IsComplete: true }; + throw err; + } + // Still present — keep draining + deleting until it's gone. + await drainRecords(registryId); + try { + await client.send(new DeleteRegistryCommand({ registryId })); + } catch (err) { + if (err instanceof ResourceNotFoundException) return { IsComplete: true }; + if (!(err instanceof ConflictException)) throw err; + } + return { IsComplete: false }; + } + + // Create / Update: wait for READY. + const res = await client.send(new GetRegistryCommand({ registryId })); + const status = res.status ?? ''; + if (status === 'READY') { + return { IsComplete: true, Data: { RegistryId: registryId, RegistryArn: res.registryArn! } }; + } + if (status.includes('FAILED')) { + throw new Error(`Registry ${registryId} entered ${status}: ${res.statusReason ?? 'no reason given'}`); + } + return { IsComplete: false }; +} + +/** + * Delete every record in a registry so the registry itself can be deleted + * (DeleteRegistry ConflictExceptions while records exist). Records are also + * async and eventually consistent in List; best-effort per invocation, with + * isComplete re-invoking until the registry is empty. + */ +async function drainRecords(registryId: string): Promise { + let nextToken: string | undefined; + do { + let page; + try { + page = await client.send(new ListRegistryRecordsCommand({ registryId, nextToken, maxResults: 50 })); + } catch (err) { + if (err instanceof ResourceNotFoundException) return; + throw err; + } + const records = page.registryRecords ?? []; + for (const rec of records) { + const recordId = rec.recordArn ? registryIdFromArn(rec.recordArn) : rec.recordId; + if (!recordId) continue; + try { + await client.send(new DeleteRegistryRecordCommand({ registryId, recordId })); + } catch (err) { + // CREATING/UPDATING records reject delete; isComplete retries next poll. + if (!(err instanceof ConflictException) && !(err instanceof ResourceNotFoundException)) throw err; + } + } + nextToken = page.nextToken; + } while (nextToken); +} diff --git a/cdk/src/handlers/registry-publish.ts b/cdk/src/handlers/registry-publish.ts new file mode 100644 index 000000000..9ece57e1a --- /dev/null +++ b/cdk/src/handlers/registry-publish.ts @@ -0,0 +1,134 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { ConflictException } from '@aws-sdk/client-bedrock-agentcore-control'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { ulid } from 'ulid'; +import { extractUserId, userInGroup } from './shared/gateway'; +import { logger } from './shared/logger'; +import { + REGISTRY_APPROVER_GROUP, + REGISTRY_PUBLISHER_GROUP, + makeRegistryClient, +} from './shared/registry/factory'; +import { REGISTRY_KINDS, RESERVED_KINDS, parseConstraint } from './shared/registry/ref'; +import type { PublishInput, RuntimePayload } from './shared/registry/types'; +import { ErrorCode, errorResponse, successResponse } from './shared/response'; +import type { RegistryPublishRequest, RegistryRecordResponse } from './shared/types'; + +const NAMESPACE_RE = /^[a-z][a-z0-9-]*$/; +const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/; + +/** + * POST /v1/registry/records — publish an asset record. + * + * Auth: caller must be a `RegistryPublisher`. `auto_approve` additionally + * requires `RegistryApprover` (it drives the record all the way to APPROVED). + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + const requestId = ulid(); + try { + const userId = extractUserId(event); + if (!userId) { + return errorResponse(401, ErrorCode.UNAUTHORIZED, 'Missing or invalid authentication.', requestId); + } + if (!userInGroup(event, REGISTRY_PUBLISHER_GROUP)) { + return errorResponse(403, ErrorCode.FORBIDDEN, `Publishing requires the ${REGISTRY_PUBLISHER_GROUP} group.`, requestId); + } + + const body = parseBody(event.body); + if (!body) { + return errorResponse(400, ErrorCode.VALIDATION_ERROR, 'Request body must be valid JSON.', requestId); + } + + const validationError = validate(body); + if (validationError) { + return errorResponse(400, ErrorCode.VALIDATION_ERROR, validationError, requestId); + } + + if (body.auto_approve && !userInGroup(event, REGISTRY_APPROVER_GROUP)) { + return errorResponse(403, ErrorCode.FORBIDDEN, `auto_approve requires the ${REGISTRY_APPROVER_GROUP} group.`, requestId); + } + + const input: PublishInput = { + kind: body.kind, + namespace: body.namespace, + name: body.name, + version: body.asset_version, + discovery: body.discovery, + runtime: body.runtime as unknown as RuntimePayload, + custom: body.custom, + autoApprove: body.auto_approve, + }; + + const client = makeRegistryClient(); + const record = await client.publish(input); + + const response: RegistryRecordResponse = { + kind: record.kind, + namespace: record.namespace, + name: record.name, + version: record.version, + status: record.status, + storage_mode: record.storageMode, + }; + return successResponse(201, response, requestId); + } catch (err) { + if (err instanceof ConflictException) { + return errorResponse(409, ErrorCode.REGISTRY_VERSION_EXISTS, 'A record with these coordinates already exists.', requestId); + } + logger.error('registry publish failed', { requestId, error: String(err) }); + return errorResponse(500, ErrorCode.INTERNAL_ERROR, 'Failed to publish record.', requestId); + } +} + +function parseBody(raw: string | null): RegistryPublishRequest | null { + if (!raw) return null; + try { + return JSON.parse(raw) as RegistryPublishRequest; + } catch { + return null; + } +} + +/** Returns an error message, or null when the request is well-formed. */ +function validate(body: RegistryPublishRequest): string | null { + if (RESERVED_KINDS.includes(body.kind as (typeof RESERVED_KINDS)[number])) { + return `kind '${body.kind}' is reserved and cannot be published yet (no loader).`; + } + if (!REGISTRY_KINDS.includes(body.kind as (typeof REGISTRY_KINDS)[number])) { + return `unknown kind '${body.kind}' (expected one of: ${REGISTRY_KINDS.join(', ')}).`; + } + if (!body.namespace || !NAMESPACE_RE.test(body.namespace)) { + return 'namespace must match [a-z][a-z0-9-]*.'; + } + if (!body.name || !NAME_RE.test(body.name)) { + return 'name must match [a-z0-9][a-z0-9._-]*.'; + } + if (!body.asset_version || !parseConstraint(body.asset_version) || parseConstraint(body.asset_version)!.op !== 'exact') { + return 'asset_version must be an exact semver (MAJOR.MINOR.PATCH[-prerelease]).'; + } + if (!body.discovery || typeof body.discovery !== 'object') { + return 'discovery must be an object.'; + } + if (!body.runtime || typeof body.runtime !== 'object') { + return 'runtime must be an object.'; + } + return null; +} diff --git a/cdk/src/handlers/registry-resolve.ts b/cdk/src/handlers/registry-resolve.ts new file mode 100644 index 000000000..63629ed34 --- /dev/null +++ b/cdk/src/handlers/registry-resolve.ts @@ -0,0 +1,73 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { ulid } from 'ulid'; +import { extractUserId } from './shared/gateway'; +import { logger } from './shared/logger'; +import { makeRegistryClient } from './shared/registry/factory'; +import { parseRef } from './shared/registry/ref'; +import { RegistryResolutionError } from './shared/registry/types'; +import { ErrorCode, errorResponse, successResponse } from './shared/response'; +import type { RegistryResolveResponse } from './shared/types'; + +/** + * GET /v1/registry/resolve?ref=registry://kind/namespace/name@constraint + * + * Resolves a pinned ref to a single APPROVED (or DEPRECATED+warn) asset. + * Fail-closed: any unresolved ref returns 422 with a specific reason. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + const requestId = ulid(); + try { + const userId = extractUserId(event); + if (!userId) { + return errorResponse(401, ErrorCode.UNAUTHORIZED, 'Missing or invalid authentication.', requestId); + } + + const refStr = event.queryStringParameters?.ref; + if (!refStr) { + return errorResponse(400, ErrorCode.VALIDATION_ERROR, 'Missing ref query parameter.', requestId); + } + + const parsed = parseRef(refStr); + if (!parsed.ok) { + return errorResponse(422, ErrorCode.REGISTRY_RESOLUTION_FAILED, `${parsed.reason}: ${parsed.message}`, requestId); + } + + const client = makeRegistryClient(); + const asset = await client.resolve(parsed.ref); + + const response: RegistryResolveResponse = { + kind: asset.kind, + namespace: asset.namespace, + name: asset.name, + version: asset.version, + runtime: asset.runtime as unknown as Record, + warnings: asset.warnings, + }; + return successResponse(200, response, requestId); + } catch (err) { + if (err instanceof RegistryResolutionError) { + return errorResponse(422, ErrorCode.REGISTRY_RESOLUTION_FAILED, `${err.reason}: ${err.message}`, requestId); + } + logger.error('registry resolve failed', { requestId, error: String(err) }); + return errorResponse(500, ErrorCode.INTERNAL_ERROR, 'Failed to resolve ref.', requestId); + } +} diff --git a/cdk/src/handlers/registry-show.ts b/cdk/src/handlers/registry-show.ts new file mode 100644 index 000000000..d40c699a8 --- /dev/null +++ b/cdk/src/handlers/registry-show.ts @@ -0,0 +1,73 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { ulid } from 'ulid'; +import { extractUserId } from './shared/gateway'; +import { logger } from './shared/logger'; +import { makeRegistryClient } from './shared/registry/factory'; +import { compareVersions, parseVersion } from './shared/registry/resolver'; +import { ErrorCode, errorResponse, successResponse } from './shared/response'; +import type { RegistryVersionSummary } from './shared/types'; + +/** + * GET /v1/registry/records/{kind}/{namespace}/{name} — show every version of + * one asset with its status/publisher/created_at. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + const requestId = ulid(); + try { + const userId = extractUserId(event); + if (!userId) { + return errorResponse(401, ErrorCode.UNAUTHORIZED, 'Missing or invalid authentication.', requestId); + } + + const kind = event.pathParameters?.kind; + const namespace = event.pathParameters?.namespace; + const name = event.pathParameters?.name; + if (!kind || !namespace || !name) { + return errorResponse(400, ErrorCode.VALIDATION_ERROR, 'Missing kind/namespace/name path parameters.', requestId); + } + + const client = makeRegistryClient(); + const records = (await client.listRecords({ kind, namespace })).filter((r) => r.name === name); + if (records.length === 0) { + return errorResponse(404, ErrorCode.REGISTRY_RECORD_NOT_FOUND, `No asset ${kind}/${namespace}/${name}.`, requestId); + } + + const versions: RegistryVersionSummary[] = records + .map((r) => ({ + version: r.version, + status: r.status, + created_at: r.createdAt ?? null, + publisher: r.publisher ?? null, + })) + .sort((a, b) => { + const av = parseVersion(a.version); + const bv = parseVersion(b.version); + if (!av || !bv) return 0; + return compareVersions(bv, av); // highest first + }); + + return successResponse(200, { kind, namespace, name, versions }, requestId); + } catch (err) { + logger.error('registry show failed', { requestId, error: String(err) }); + return errorResponse(500, ErrorCode.INTERNAL_ERROR, 'Failed to show asset.', requestId); + } +} diff --git a/cdk/src/handlers/shared/gateway.ts b/cdk/src/handlers/shared/gateway.ts index cdfa5b3a5..aca8abf71 100644 --- a/cdk/src/handlers/shared/gateway.ts +++ b/cdk/src/handlers/shared/gateway.ts @@ -40,6 +40,22 @@ export function extractUserId(event: APIGatewayProxyEvent): string | null { return null; } +/** + * Check whether the authenticated caller is in a Cognito group. Cognito places + * group membership in the `cognito:groups` claim, which the authorizer surfaces + * either as a comma/space-separated string or an array depending on the token + * shape — this normalizes both. Used to gate registry publish/approve (#246). + * @param event - the API Gateway proxy event. + * @param group - the Cognito group name to check for. + * @returns true if the caller is a member of `group`. + */ +export function userInGroup(event: APIGatewayProxyEvent, group: string): boolean { + const raw = event.requestContext.authorizer?.claims?.['cognito:groups']; + if (!raw) return false; + const groups = Array.isArray(raw) ? raw : String(raw).split(/[,\s]+/); + return groups.includes(group); +} + /** * Generate a branch name from task ID and description. * Pattern: `bgagent/{taskId}/{slug}` diff --git a/cdk/src/handlers/shared/registry/agentcore-client.ts b/cdk/src/handlers/shared/registry/agentcore-client.ts new file mode 100644 index 000000000..665781abf --- /dev/null +++ b/cdk/src/handlers/shared/registry/agentcore-client.ts @@ -0,0 +1,394 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// The ONE AgentCore-aware implementation of the `RegistryClient` port (#246). +// This is the only file upstream of the port that imports the AWS SDK. It owns +// the substrate-specific decisions established by the live spikes +// (ISSUE_246_AGENTCORE_FINDINGS.md): +// +// - namespace-in-`name` encoding (Option A): AgentCore has no namespace, so we +// fold `kind/namespace/name` into the record `name` and split on read. +// - native-vs-CUSTOM storage: purist native descriptors (MCP/AGENT_SKILLS) +// by default, carrying ABCA runtime config in a `_meta` block; `custom:true` +// stores a verbatim CUSTOM body instead. +// - 3-call publish: CreateRegistryRecord is async and lands in DRAFT even with +// registry autoApproval; `autoApprove` drives create→submit→approve. +// - resolve ranks semver in code (AgentCore stores a plain version string). + +import { + BedrockAgentCoreControlClient, + CreateRegistryRecordCommand, + GetRegistryRecordCommand, + ListRegistryRecordsCommand, + SubmitRegistryRecordForApprovalCommand, + UpdateRegistryRecordStatusCommand, + ConflictException, + ResourceNotFoundException, +} from '@aws-sdk/client-bedrock-agentcore-control'; +import type { RegistryClient } from './client'; +import type { ParsedRef } from './ref'; +import { selectHighest } from './resolver'; +import { + RUNTIME_META_KEY, + RegistryResolutionError, + type ListFilter, + type PublishInput, + type RegistryRecord, + type RegistryStatus, + type ResolvedAsset, + type RuntimePayload, + type StorageMode, +} from './types'; + +const NAME_SEP = '/'; +const RECORD_CREATE_POLL_MS = 2000; +const RECORD_CREATE_MAX_POLLS = 30; + +/** Kinds that map onto a native AgentCore descriptor type. */ +const NATIVE_DESCRIPTOR_BY_KIND: Record = { + mcp_server: 'MCP', + skill: 'AGENT_SKILLS', +}; + +/** Frontmatter key carrying the ABCA runtime payload (JSON) inside a native + * AGENT_SKILLS SKILL.md — the AGENT_SKILLS validator requires Markdown + * frontmatter (not JSON), so the MCP `_meta` convention can't be reused here. */ +const SKILL_RUNTIME_FM_KEY = 'x-abca-runtime'; +const SKILL_NAME_MAX = 64; + +/** Derive a SKILL.md `name` from namespace/name: the AGENT_SKILLS validator + * requires 1-64 lowercase alphanumerics + single hyphens (no slash, no + * leading/trailing/consecutive hyphens). */ +function skillNameSlug(namespace: string, name: string): string { + return `${namespace}-${name}` + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, SKILL_NAME_MAX) + .replace(/-$/, ''); +} + +/** Build a valid SKILL.md (frontmatter + body) carrying discovery metadata and + * the ABCA runtime payload in the `x-abca-runtime` frontmatter key. */ +function buildSkillMd(input: { + namespace: string; + name: string; + version: string; + discovery: Readonly>; + runtime: unknown; +}): string { + const description = String( + input.discovery.description ?? input.discovery.summary ?? `${input.namespace}/${input.name} skill`, + ).slice(0, 100); + const runtimeJson = JSON.stringify(input.runtime); + return [ + '---', + `name: ${skillNameSlug(input.namespace, input.name)}`, + `description: ${description}`, + `version: ${input.version}`, + `${SKILL_RUNTIME_FM_KEY}: '${runtimeJson}'`, + '---', + `# ${input.namespace}/${input.name}`, + '', + String(input.discovery.body ?? 'ABCA registry skill.'), + ].join('\n'); +} + +/** Recover the ABCA runtime payload from a SKILL.md's `x-abca-runtime` + * frontmatter line. Mirrors ``agent/src/registry/agentcore_client.py``. */ +function parseSkillRuntime(skillMd: string): unknown { + const m = skillMd.match(new RegExp(`^${SKILL_RUNTIME_FM_KEY}:\\s*'(.+)'\\s*$`, 'm')); + return m ? JSON.parse(m[1]) : {}; +} + +export interface AgentCoreRegistryClientOptions { + readonly registryId: string; + /** Injectable for tests; defaults to a real client in the target region. */ + readonly client?: BedrockAgentCoreControlClient; +} + +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); + +export class AgentCoreRegistryClient implements RegistryClient { + private readonly client: BedrockAgentCoreControlClient; + private readonly registryId: string; + + constructor(opts: AgentCoreRegistryClientOptions) { + this.registryId = opts.registryId; + this.client = opts.client ?? new BedrockAgentCoreControlClient({}); + } + + // --- name (Option A) encode/decode ------------------------------------------ + + private encodeName(kind: string, namespace: string, name: string): string { + return [kind, namespace, name].join(NAME_SEP); + } + + private decodeName(recordName: string): { kind: string; namespace: string; name: string } { + const [kind, namespace, ...rest] = recordName.split(NAME_SEP); + return { kind, namespace, name: rest.join(NAME_SEP) }; + } + + // --- publish (3-call) ------------------------------------------------------- + + async publish(input: PublishInput): Promise { + const useCustom = input.custom || !(input.kind in NATIVE_DESCRIPTOR_BY_KIND); + const name = this.encodeName(input.kind, input.namespace, input.name); + + // Immutability: reject a re-publish of the same coordinates. + const existing = await this.getRecord(input.kind, input.namespace, input.name, input.version); + if (existing) { + throw new ConflictException({ + message: `record ${name}@${input.version} already exists`, + $metadata: {}, + }); + } + + const descriptors = useCustom + ? { custom: { inlineContent: JSON.stringify(this.customBody(input)) } } + : this.nativeDescriptors(input); + + let recordId: string; + try { + const res = await this.client.send( + new CreateRegistryRecordCommand({ + registryId: this.registryId, + name, + descriptorType: useCustom ? 'CUSTOM' : NATIVE_DESCRIPTOR_BY_KIND[input.kind], + descriptors, + recordVersion: input.version, + }), + ); + recordId = this.idFromArn(res.recordArn!); + } catch (err) { + if (err instanceof ConflictException) throw err; + throw err; + } + + // CreateRegistryRecord is async — wait until it leaves CREATING. + await this.waitPastCreating(recordId); + + if (input.autoApprove) { + // DRAFT -> PENDING_APPROVAL -> APPROVED (submit is a mandatory waypoint). + await this.client.send( + new SubmitRegistryRecordForApprovalCommand({ registryId: this.registryId, recordId }), + ); + await this.client.send( + new UpdateRegistryRecordStatusCommand({ + registryId: this.registryId, + recordId, + status: 'APPROVED', + statusReason: 'auto-approved on publish', + }), + ); + } + + const record = await this.getRecordById(recordId); + if (!record) throw new Error(`published record ${recordId} not readable after write`); + return record; + } + + // --- get / list ------------------------------------------------------------- + + async getRecord( + kind: string, + namespace: string, + name: string, + version: string, + ): Promise { + // AgentCore keys records by opaque id, not our coordinates, and List is + // eventually consistent — so scan the (small) record set and match. + const records = await this.listRecords({ kind, namespace }); + return records.find((r) => r.name === name && r.version === version) ?? null; + } + + async listRecords(filter?: ListFilter): Promise { + const out: RegistryRecord[] = []; + let nextToken: string | undefined; + do { + const page = await this.client.send( + new ListRegistryRecordsCommand({ + registryId: this.registryId, + nextToken, + maxResults: 50, + }), + ); + for (const summary of page.registryRecords ?? []) { + const decoded = this.decodeName(summary.name ?? ''); + if (filter?.kind && decoded.kind !== filter.kind) continue; + if (filter?.namespace && decoded.namespace !== filter.namespace) continue; + const recordId = summary.recordArn ? this.idFromArn(summary.recordArn) : summary.recordId; + if (!recordId) continue; + const full = await this.getRecordById(recordId); + if (full) out.push(full); + } + nextToken = page.nextToken; + } while (nextToken); + return out; + } + + // --- resolve ---------------------------------------------------------------- + + async resolve(ref: ParsedRef): Promise { + const refStr = `registry://${ref.kind}/${ref.namespace}/${ref.name}@${ref.constraint.raw}`; + const all = await this.listRecords({ kind: ref.kind, namespace: ref.namespace }); + const forName = all.filter((r) => r.name === ref.name); + + // Only APPROVED / DEPRECATED are resolution candidates. + const candidates = forName.filter( + (r) => r.status === 'APPROVED' || r.status === 'DEPRECATED', + ); + const winningVersion = selectHighest( + candidates.map((r) => r.version), + ref.constraint, + ); + if (!winningVersion) { + throw new RegistryResolutionError( + 'NO_MATCHING_VERSION', + refStr, + `no approved version of ${ref.kind}/${ref.namespace}/${ref.name} satisfies ${ref.constraint.raw}`, + ); + } + const winner = candidates.find((r) => r.version === winningVersion)!; + const warnings = winner.status === 'DEPRECATED' ? ['DEPRECATED'] : []; + return { + kind: winner.kind, + namespace: winner.namespace, + name: winner.name, + version: winner.version, + runtime: winner.runtime, + warnings, + }; + } + + // --- internals -------------------------------------------------------------- + + private idFromArn(arn: string): string { + return arn.includes('/') ? arn.split('/').pop()! : arn; + } + + private async waitPastCreating(recordId: string): Promise { + for (let i = 0; i < RECORD_CREATE_MAX_POLLS; i++) { + try { + const rec = await this.client.send( + new GetRegistryRecordCommand({ registryId: this.registryId, recordId }), + ); + if (!String(rec.status).includes('CREATING')) return; + } catch (err) { + if (err instanceof ResourceNotFoundException) return; + throw err; + } + await sleep(RECORD_CREATE_POLL_MS); + } + } + + private async getRecordById(recordId: string): Promise { + let raw; + try { + raw = await this.client.send( + new GetRegistryRecordCommand({ registryId: this.registryId, recordId }), + ); + } catch (err) { + if (err instanceof ResourceNotFoundException) return null; + throw err; + } + const decoded = this.decodeName(raw.name ?? ''); + const { runtime, storageMode, discovery } = this.extractPayload(raw); + return { + kind: decoded.kind, + namespace: decoded.namespace, + name: decoded.name, + version: raw.recordVersion ?? '', + status: (raw.status ?? 'DRAFT') as RegistryStatus, + storageMode, + discovery, + runtime, + createdAt: raw.createdAt ? raw.createdAt.toISOString() : undefined, + }; + } + + /** Pull the ABCA runtime payload back out of the descriptor (native `_meta` or + * the verbatim CUSTOM body). */ + private extractPayload(raw: { + descriptorType?: string; + descriptors?: { + custom?: { inlineContent?: string }; + mcp?: { server?: { inlineContent?: string } }; + agentSkills?: { skillMd?: { inlineContent?: string } }; + }; + }): { runtime: RuntimePayload; storageMode: StorageMode; discovery: Record } { + if (raw.descriptorType === 'CUSTOM') { + const body = JSON.parse(raw.descriptors?.custom?.inlineContent ?? '{}'); + return { + runtime: body.runtime as RuntimePayload, + storageMode: 'custom', + discovery: (body.discovery ?? {}) as Record, + }; + } + if (raw.descriptorType === 'AGENT_SKILLS') { + // SKILL.md is Markdown frontmatter, not JSON — recover the runtime from + // the `x-abca-runtime` frontmatter key. + const skillMd = raw.descriptors?.agentSkills?.skillMd?.inlineContent ?? ''; + return { + runtime: parseSkillRuntime(skillMd) as RuntimePayload, + storageMode: 'native', + discovery: { skillMd }, + }; + } + // MCP: JSON server.json with the runtime in a `_meta` block. + const inline = raw.descriptors?.mcp?.server?.inlineContent ?? '{}'; + const body = JSON.parse(inline); + const meta = body._meta?.[RUNTIME_META_KEY]; + return { + runtime: meta as RuntimePayload, + storageMode: 'native', + discovery: body as Record, + }; + } + + private customBody(input: PublishInput): Record { + return { + abca_kind: input.kind, + discovery: input.discovery, + runtime: input.runtime, + }; + } + + private nativeDescriptors(input: PublishInput): { + mcp?: { server: { inlineContent: string } }; + agentSkills?: { skillMd: { inlineContent: string } }; + } { + if (NATIVE_DESCRIPTOR_BY_KIND[input.kind] === 'MCP') { + // MCP: embed the runtime in a `_meta` block on the validated server.json. + const withMeta = { ...input.discovery, _meta: { [RUNTIME_META_KEY]: input.runtime } }; + return { mcp: { server: { inlineContent: JSON.stringify(withMeta) } } }; + } + // AGENT_SKILLS: the validator requires Markdown frontmatter (not JSON), so + // the runtime rides in an `x-abca-runtime` frontmatter key inside SKILL.md. + const skillMd = buildSkillMd({ + namespace: input.namespace, + name: input.name, + version: input.version, + discovery: input.discovery, + runtime: input.runtime, + }); + return { agentSkills: { skillMd: { inlineContent: skillMd } } }; + } +} diff --git a/cdk/src/handlers/shared/registry/client.ts b/cdk/src/handlers/shared/registry/client.ts new file mode 100644 index 000000000..59defab55 --- /dev/null +++ b/cdk/src/handlers/shared/registry/client.ts @@ -0,0 +1,61 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// The `RegistryClient` port (#246). Every consumer — handlers, the orchestrator +// resolve-step — talks to the registry through this interface, NEVER a raw AWS +// SDK client. The one implementation is `AgentCoreRegistryClient`; swapping the +// substrate (or absorbing the AgentCore GA namespace migration on 2026-08-06) is +// confined to that adapter file, not the call sites. + +import type { ParsedRef } from './ref'; +import type { + ListFilter, + PublishInput, + RegistryRecord, + ResolvedAsset, +} from './types'; + +export interface RegistryClient { + /** + * Publish a record. On the AgentCore substrate this is a multi-step operation + * (create → poll READY-ish → submit → approve when `autoApprove`); the port + * hides that so callers see a single verb. Returns the created record. + * Throws on an immutability collision (same kind/namespace/name/version). + */ + publish(input: PublishInput): Promise; + + /** Fetch a single record by its exact coordinates, or null if absent. */ + getRecord( + kind: string, + namespace: string, + name: string, + version: string, + ): Promise; + + /** List records (optionally filtered by kind/namespace). */ + listRecords(filter?: ListFilter): Promise; + + /** + * Resolve a parsed ref to a single asset: gather candidate versions, rank by + * semver, apply the constraint + status rules (only APPROVED resolves; + * DEPRECATED resolves with a warning). Throws `RegistryResolutionError` with a + * specific reason on failure — resolution is fail-closed. + */ + resolve(ref: ParsedRef): Promise; +} diff --git a/cdk/src/handlers/shared/registry/factory.ts b/cdk/src/handlers/shared/registry/factory.ts new file mode 100644 index 000000000..5b75d2663 --- /dev/null +++ b/cdk/src/handlers/shared/registry/factory.ts @@ -0,0 +1,37 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// Single place handlers obtain a `RegistryClient`. Keeping the concrete adapter +// choice here (not in each handler) means the substrate swap touches one file. + +import { AgentCoreRegistryClient } from './agentcore-client'; +import type { RegistryClient } from './client'; + +/** Cognito group names that gate publish / approval (#246, REGISTRY.md §10). */ +export const REGISTRY_PUBLISHER_GROUP = 'RegistryPublisher'; +export const REGISTRY_APPROVER_GROUP = 'RegistryApprover'; + +/** Build the registry client from the handler's environment. */ +export function makeRegistryClient(): RegistryClient { + const registryId = process.env.AGENT_REGISTRY_ID; + if (!registryId) { + throw new Error('AGENT_REGISTRY_ID env var is not set'); + } + return new AgentCoreRegistryClient({ registryId }); +} diff --git a/cdk/src/handlers/shared/registry/ref.ts b/cdk/src/handlers/shared/registry/ref.ts new file mode 100644 index 000000000..cedbcdf89 --- /dev/null +++ b/cdk/src/handlers/shared/registry/ref.ts @@ -0,0 +1,121 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// Strict `registry://` reference grammar for the agent asset registry (#246). +// +// registry:////@ +// kind = [a-z][a-z0-9_]* snake_case: mcp_server, cedar_policy_module +// namespace = [a-z][a-z0-9-]* +// name = [a-z0-9][a-z0-9._-]* +// constraint = [^~]?MAJOR.MINOR.PATCH[-prerelease] exact / caret / tilde only +// +// The `@` pin is MANDATORY (fail-closed: no implicit "latest"). +// This grammar is mirrored byte-for-byte by `parse_ref` in +// agent/src/registry/ref.py and exercised by the contracts/registry-resolution/ +// parity corpus. Keep the two in lockstep. + +/** MVP asset kinds the registry loads end-to-end or stages. */ +export const REGISTRY_KINDS = [ + 'mcp_server', + 'cedar_policy_module', + 'skill', +] as const; +export type RegistryKind = (typeof REGISTRY_KINDS)[number]; + +/** Reserved kinds accepted by the grammar but rejected at publish (no loader yet). */ +export const RESERVED_KINDS = ['plugin', 'subagent', 'prompt_fragment', 'capability'] as const; + +export type ConstraintOp = 'exact' | 'caret' | 'tilde'; + +export interface ParsedConstraint { + readonly op: ConstraintOp; + readonly major: number; + readonly minor: number; + readonly patch: number; + /** Prerelease tag without the leading `-`, or undefined. */ + readonly prerelease?: string; + /** The constraint exactly as written (e.g. `^1.4.1`). */ + readonly raw: string; +} + +export interface ParsedRef { + readonly kind: string; + readonly namespace: string; + readonly name: string; + readonly constraint: ParsedConstraint; +} + +export type RefErrorReason = 'INVALID_REGISTRY_REF' | 'INVALID_CONSTRAINT'; + +export type ParseResult = + | { readonly ok: true; readonly ref: ParsedRef } + | { readonly ok: false; readonly reason: RefErrorReason; readonly message: string }; + +// Structural split — validates the scheme + 3 path segments and captures the +// (mandatory) constraint. Segment character classes are validated separately so +// we can distinguish a bad ref shape from a bad constraint. +const REF_SHAPE = + /^registry:\/\/([a-z][a-z0-9_]*)\/([a-z][a-z0-9-]*)\/([a-z0-9][a-z0-9._-]*)@(.+)$/; + +// exact / caret / tilde over MAJOR.MINOR.PATCH with an optional prerelease. +// Rejects `*`, `latest`, `>=`, `<=`, x-ranges, and bare prerelease modifiers. +const CONSTRAINT = + /^([\^~]?)(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?$/; + +const OP_BY_PREFIX: Record = { '': 'exact', '^': 'caret', '~': 'tilde' }; + +/** Parse + validate a constraint string in isolation (exported for the resolver). */ +export function parseConstraint(raw: string): ParsedConstraint | null { + const m = CONSTRAINT.exec(raw); + if (!m) return null; + return { + op: OP_BY_PREFIX[m[1]], + major: Number(m[2]), + minor: Number(m[3]), + patch: Number(m[4]), + prerelease: m[5], + raw, + }; +} + +/** + * Parse a strict `registry://kind/namespace/name@constraint` reference. + * A ref with no `@constraint`, or a floating constraint (`*`, `latest`, `>=`…), + * is rejected — pins are mandatory. + */ +export function parseRef(ref: string): ParseResult { + const shape = REF_SHAPE.exec(ref); + if (!shape) { + return { + ok: false, + reason: 'INVALID_REGISTRY_REF', + message: `not a valid registry ref (expected registry://kind/namespace/name@constraint): ${ref}`, + }; + } + const [, kind, namespace, name, rawConstraint] = shape; + const constraint = parseConstraint(rawConstraint); + if (!constraint) { + return { + ok: false, + reason: 'INVALID_CONSTRAINT', + message: `unsupported version constraint '${rawConstraint}' (use exact, ^, or ~)`, + }; + } + return { ok: true, ref: { kind, namespace, name, constraint } }; +} diff --git a/cdk/src/handlers/shared/registry/resolver.ts b/cdk/src/handlers/shared/registry/resolver.ts new file mode 100644 index 000000000..5b90ce220 --- /dev/null +++ b/cdk/src/handlers/shared/registry/resolver.ts @@ -0,0 +1,161 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// Semver constraint matching + highest-version selection for the agent asset +// registry (#246). AgentCore stores a plain version STRING with no native `^`/`~` +// matching, so ranking is always done here in code (this is substrate-agnostic +// ABCA logic — it does not import the AWS SDK). + +import { type ParsedConstraint, parseConstraint } from './ref'; + +export interface SemVer { + readonly major: number; + readonly minor: number; + readonly patch: number; + /** Dot-separated prerelease identifiers, or [] for a release version. */ + readonly prerelease: readonly string[]; + readonly raw: string; +} + +const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?$/; + +/** Parse a plain version string (no operator). Returns null if not valid semver. */ +export function parseVersion(raw: string): SemVer | null { + const m = SEMVER.exec(raw); + if (!m) return null; + return { + major: Number(m[1]), + minor: Number(m[2]), + patch: Number(m[3]), + prerelease: m[4] ? m[4].split('.') : [], + raw, + }; +} + +function isNumeric(s: string): boolean { + return /^\d+$/.test(s); +} + +/** + * Compare two prerelease identifier lists per semver §11: a release (empty list) + * outranks any prerelease; numeric identifiers compare numerically; identifiers + * are compared field by field; a longer list wins when all shared fields tie. + */ +function comparePrerelease(a: readonly string[], b: readonly string[]): number { + if (a.length === 0 && b.length === 0) return 0; + if (a.length === 0) return 1; // a is a release → higher + if (b.length === 0) return -1; // b is a release → higher + const len = Math.min(a.length, b.length); + for (let i = 0; i < len; i++) { + const ai = a[i]; + const bi = b[i]; + if (ai === bi) continue; + const an = isNumeric(ai); + const bn = isNumeric(bi); + if (an && bn) return Number(ai) - Number(bi); + if (an) return -1; // numeric identifiers have lower precedence than alphanumeric + if (bn) return 1; + return ai < bi ? -1 : 1; + } + return a.length - b.length; +} + +/** Total order over versions: <0 if a0 if a>b. */ +export function compareVersions(a: SemVer, b: SemVer): number { + if (a.major !== b.major) return a.major - b.major; + if (a.minor !== b.minor) return a.minor - b.minor; + if (a.patch !== b.patch) return a.patch - b.patch; + return comparePrerelease(a.prerelease, b.prerelease); +} + +function coreEquals(v: SemVer, c: ParsedConstraint): boolean { + return v.major === c.major && v.minor === c.minor && v.patch === c.patch; +} + +/** + * Does a version satisfy a constraint? + * + * - exact: identical core AND identical prerelease. + * - caret `^1.4.1`: `>=1.4.1 <2.0.0` (same major; `^0.x` keeps minor per npm). + * - tilde `~1.4.1`: `>=1.4.1 <1.5.0` (same major.minor). + * + * A prerelease version only satisfies a range when the constraint itself pins the + * same core version and carries a prerelease (npm semantics) — otherwise + * prereleases are excluded from range matches so `^1.4.1` never picks `1.5.0-rc.1`. + */ +export function satisfies(v: SemVer, c: ParsedConstraint): boolean { + const constraintCore: SemVer = { + major: c.major, + minor: c.minor, + patch: c.patch, + prerelease: c.prerelease ? c.prerelease.split('.') : [], + raw: c.raw, + }; + + if (c.op === 'exact') { + return compareVersions(v, constraintCore) === 0; + } + + // Range ops: v must be >= the constraint's core. + if (compareVersions(v, constraintCore) < 0) return false; + + // Exclude prereleases from range matches unless the constraint pins the same + // core version and is itself a prerelease. + if (v.prerelease.length > 0) { + if (!(coreEquals(v, c) && constraintCore.prerelease.length > 0)) return false; + } + + if (c.op === 'caret') { + if (c.major > 0) return v.major === c.major; + // ^0.x.y → same major.minor (npm behavior for 0.x) + if (c.minor > 0) return v.major === 0 && v.minor === c.minor; + // ^0.0.z → exact patch + return v.major === 0 && v.minor === 0 && v.patch === c.patch; + } + + // tilde: same major.minor + return v.major === c.major && v.minor === c.minor; +} + +/** + * From a set of candidate versions (plain strings), pick the highest that + * satisfies the constraint. Unparseable versions are skipped. Returns the + * winning raw string, or null when nothing matches. + */ +export function selectHighest( + candidates: readonly string[], + constraint: ParsedConstraint, +): string | null { + let best: SemVer | null = null; + for (const raw of candidates) { + const v = parseVersion(raw); + if (!v || !satisfies(v, constraint)) continue; + if (best === null || compareVersions(v, best) > 0) best = v; + } + return best ? best.raw : null; +} + +/** Convenience: parse a constraint string then select. Null on bad constraint. */ +export function selectHighestForConstraint( + candidates: readonly string[], + constraintRaw: string, +): string | null { + const c = parseConstraint(constraintRaw); + return c ? selectHighest(candidates, c) : null; +} diff --git a/cdk/src/handlers/shared/registry/types.ts b/cdk/src/handlers/shared/registry/types.ts new file mode 100644 index 000000000..32cdcf56e --- /dev/null +++ b/cdk/src/handlers/shared/registry/types.ts @@ -0,0 +1,147 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// Substrate-neutral domain types for the agent asset registry (#246). These are +// the types the `RegistryClient` port speaks — nothing here references AgentCore +// or the AWS SDK. The AgentCore-specific mapping lives in agentcore-client.ts. +// +// API *wire* types shared with the CLI live in ../types.ts (types-sync contract); +// these port-internal types are deliberately kept out of that sync. + +/** Canonical record status. Mirrors AgentCore's `RegistryRecordStatus` tokens so + * the resolver's status filter is spelled identically on both sides. Only + * `APPROVED` resolves; `DEPRECATED` resolves with a warning. */ +export type RegistryStatus = + | 'CREATING' + | 'DRAFT' + | 'PENDING_APPROVAL' + | 'APPROVED' + | 'REJECTED' + | 'DEPRECATED' + | 'UPDATING' + | 'CREATE_FAILED' + | 'UPDATE_FAILED'; + +/** How a record's runtime payload is stored on the substrate. */ +export type StorageMode = 'native' | 'custom'; + +/** The reverse-DNS key under which ABCA runtime config rides inside a native + * MCP `server.json` `_meta` block (spike-verified to survive validation). */ +export const RUNTIME_META_KEY = 'dev.abca.runtime'; + +// --- Per-kind runtime payloads ------------------------------------------------- +// The loadable body each kind carries, independent of discovery metadata. + +/** mcp_server: the `.mcp.json` connection config the agent merges in. */ +export interface McpRuntimePayload { + readonly transport: 'http' | 'sse' | 'stdio'; + readonly url?: string; + readonly command?: string; + readonly args?: readonly string[]; + readonly headers?: Readonly>; + /** Tools surface under this prefix (e.g. `mcp__example__`). */ + readonly tool_prefix?: string; +} + +/** cedar_policy_module: Cedar policy source text. */ +export interface CedarRuntimePayload { + readonly cedar_text: string; +} + +/** skill: the prompt fragment appended to the system prompt (+ advisory hints). */ +export interface SkillRuntimePayload { + readonly prompt_fragment: string; + readonly tool_hints?: readonly string[]; +} + +export type RuntimePayload = + | McpRuntimePayload + | CedarRuntimePayload + | SkillRuntimePayload; + +/** A full registry record as the port sees it — discovery + runtime + status. */ +export interface RegistryRecord { + readonly kind: string; + readonly namespace: string; + readonly name: string; + readonly version: string; + readonly status: RegistryStatus; + readonly storageMode: StorageMode; + /** Discovery descriptor (server.json / SKILL.md / arbitrary) — validated body. */ + readonly discovery: Readonly>; + /** ABCA runtime payload (from `_meta` or the CUSTOM body). */ + readonly runtime: RuntimePayload; + readonly publisher?: string; + readonly createdAt?: string; +} + +/** What the resolver hands back for one ref: enough to load the asset without the + * caller knowing where the bytes live. */ +export interface ResolvedAsset { + readonly kind: string; + readonly namespace: string; + readonly name: string; + readonly version: string; + readonly runtime: RuntimePayload; + /** Non-fatal advisories, e.g. `["DEPRECATED"]`. */ + readonly warnings: readonly string[]; +} + +/** The bundle threaded into the agent invocation payload after resolving all of a + * Blueprint's `registry://` refs. */ +export interface ResolvedAssetBundle { + readonly assets: readonly ResolvedAsset[]; +} + +export type ResolutionFailureReason = + | 'NO_MATCHING_VERSION' + | 'REMOVED' + | 'INVALID_CONSTRAINT' + | 'INVALID_REGISTRY_REF'; + +export class RegistryResolutionError extends Error { + constructor( + readonly reason: ResolutionFailureReason, + readonly ref: string, + message: string, + ) { + super(message); + this.name = 'RegistryResolutionError'; + } +} + +// --- Port input types ---------------------------------------------------------- + +export interface PublishInput { + readonly kind: string; + readonly namespace: string; + readonly name: string; + readonly version: string; + readonly discovery: Readonly>; + readonly runtime: RuntimePayload; + /** Force CUSTOM storage (verbatim) instead of a native descriptor. */ + readonly custom?: boolean; + /** Dev convenience: drive create → submit → approve so the record resolves. */ + readonly autoApprove?: boolean; +} + +export interface ListFilter { + readonly kind?: string; + readonly namespace?: string; +} diff --git a/cdk/src/handlers/shared/response.ts b/cdk/src/handlers/shared/response.ts index 481211f7c..740bc006b 100644 --- a/cdk/src/handlers/shared/response.ts +++ b/cdk/src/handlers/shared/response.ts @@ -56,6 +56,10 @@ export const ErrorCode = { REQUEST_NOT_FOUND: 'REQUEST_NOT_FOUND', REQUEST_ALREADY_DECIDED: 'REQUEST_ALREADY_DECIDED', TASK_NOT_AWAITING_APPROVAL: 'TASK_NOT_AWAITING_APPROVAL', + // Agent asset registry (#246). + REGISTRY_VERSION_EXISTS: 'REGISTRY_VERSION_EXISTS', + REGISTRY_RESOLUTION_FAILED: 'REGISTRY_RESOLUTION_FAILED', + REGISTRY_RECORD_NOT_FOUND: 'REGISTRY_RECORD_NOT_FOUND', } as const; const COMMON_HEADERS = { diff --git a/cdk/src/handlers/shared/types.ts b/cdk/src/handlers/shared/types.ts index df58f536d..639da2e49 100644 --- a/cdk/src/handlers/shared/types.ts +++ b/cdk/src/handlers/shared/types.ts @@ -47,6 +47,78 @@ export type ResolvedWorkflow = { readonly version: string; }; +/** + * A resolved registry-asset pin stamped on the TaskRecord for audit (#246): + * ``{kind, id, version}`` where ``id`` is ``namespace/name``. The full runtime + * payload is NOT persisted here — it rides in the agent invocation payload; this + * triple is the immutable record of *what* the task loaded. + */ +export type ResolvedAssetTriple = { + readonly kind: string; + readonly id: string; + readonly version: string; +}; + +// --- Agent asset registry (#246) API wire types ------------------------------ +// snake_case wire shapes shared with the CLI. The substrate-neutral *domain* +// types (RegistryRecord, ResolvedAsset, the RegistryClient port) live in +// ``handlers/shared/registry/`` and are intentionally NOT part of the CLI +// types-sync contract — only these request/response envelopes are. + +/** `POST /registry/records` request body. */ +export type RegistryPublishRequest = { + readonly kind: string; + readonly namespace: string; + readonly name: string; + /** semver string, immutable once written. */ + readonly asset_version: string; + /** discovery descriptor body (server.json / SKILL.md / arbitrary JSON). */ + readonly discovery: Record; + /** ABCA runtime payload (connection config / cedar text / prompt fragment). */ + readonly runtime: Record; + /** force CUSTOM (verbatim) storage instead of a native descriptor. */ + readonly custom?: boolean; + /** dev convenience: drive create→submit→approve so the record resolves. */ + readonly auto_approve?: boolean; +}; + +/** One version row in a `show` response. */ +export type RegistryVersionSummary = { + readonly version: string; + readonly status: string; + readonly created_at: string | null; + readonly publisher: string | null; +}; + +/** A record envelope returned by publish / show. */ +export type RegistryRecordResponse = { + readonly kind: string; + readonly namespace: string; + readonly name: string; + readonly version: string; + readonly status: string; + readonly storage_mode: string; +}; + +/** `GET /registry/resolve?ref=…` response. */ +export type RegistryResolveResponse = { + readonly kind: string; + readonly namespace: string; + readonly name: string; + readonly version: string; + readonly runtime: Record; + readonly warnings: readonly string[]; +}; + +/** One asset row in a `list` response. */ +export type RegistryListEntry = { + readonly kind: string; + readonly namespace: string; + readonly name: string; + readonly latest_version: string | null; + readonly status: string; +}; + /** Shared across all attachment interfaces. Add new types here (e.g., 'audio'). */ export type AttachmentType = 'image' | 'file' | 'url'; @@ -85,6 +157,9 @@ export interface TaskRecord { /** The pinned ``{id, version}`` this task runs. Resolved at the create-task * boundary; optional only on records that predate the cutover. */ readonly resolved_workflow?: ResolvedWorkflow; + /** Registry assets (#246) resolved for this task, stamped by the orchestrator + * at task start for audit. Absent when the blueprint pins no assets. */ + readonly resolved_assets?: ResolvedAssetTriple[]; readonly pr_number?: number; readonly task_description?: string; readonly branch_name: string; @@ -265,6 +340,8 @@ export interface TaskDetail { readonly repo: string | null; readonly issue_number: number | null; readonly resolved_workflow: ResolvedWorkflow | null; + /** Registry assets resolved for this task (#246); null when none pinned. */ + readonly resolved_assets: ResolvedAssetTriple[] | null; readonly pr_number: number | null; readonly task_description: string | null; readonly branch_name: string; @@ -339,6 +416,8 @@ export interface TaskSummary { readonly repo: string | null; readonly issue_number: number | null; readonly resolved_workflow: ResolvedWorkflow | null; + /** Registry assets resolved for this task (#246); null when none pinned. */ + readonly resolved_assets: ResolvedAssetTriple[] | null; readonly pr_number: number | null; readonly task_description: string | null; readonly branch_name: string; @@ -713,6 +792,7 @@ export function toTaskDetail(record: TaskRecord): TaskDetail { repo: record.repo ?? null, issue_number: record.issue_number ?? null, resolved_workflow: record.resolved_workflow ?? null, + resolved_assets: record.resolved_assets ?? null, pr_number: record.pr_number ?? null, task_description: record.task_description ?? null, branch_name: record.branch_name, @@ -982,6 +1062,7 @@ export function toTaskSummary(record: TaskRecord): TaskSummary { repo: record.repo ?? null, issue_number: record.issue_number ?? null, resolved_workflow: record.resolved_workflow ?? null, + resolved_assets: record.resolved_assets ?? null, pr_number: record.pr_number ?? null, task_description: record.task_description ?? null, branch_name: record.branch_name, diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index 06a98deb3..045d6ae36 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -47,6 +47,7 @@ import { GitHubScreenshotIntegration } from '../constructs/github-screenshot-int import { JiraIntegration } from '../constructs/jira-integration'; import { LinearIntegration } from '../constructs/linear-integration'; import { PendingUploadCleanup } from '../constructs/pending-upload-cleanup'; +import { AgentRegistryStack } from '../constructs/registry'; import { RepoTable } from '../constructs/repo-table'; import { SlackIntegration } from '../constructs/slack-integration'; import { StrandedTaskReconciler } from '../constructs/stranded-task-reconciler'; @@ -109,6 +110,20 @@ export class AgentStack extends Stack { const apiKeyTable = new ApiKeyTable(this, 'ApiKeyTable'); const repoTable = new RepoTable(this, 'RepoTable'); + // AgentCore-backed asset registry (#246). Provisioned via a custom resource + // because CreateRegistry is async and has no CDK L2 during preview. + // GA-throwaway — swap for the native construct at GA. Registry names allow + // only alphanumerics + underscores, so sanitize the stack name. + // + // Isolated in a NestedStack: the registry + its Provider framework add ~20 + // resources; nesting keeps the root stack under CloudFormation's hard + // 500-resource limit. registryId/registryArn cross the boundary via CDK's + // automatic cross-stack export/import. + const agentRegistry = new AgentRegistryStack(this, 'AgentRegistryStack', { + registryName: `abca_${this.stackName.replace(/[^a-zA-Z0-9]/g, '_')}`, + description: 'ABCA agent asset registry (#246)', + }); + // Cedar-wasm Lambda layer (§15.2 task 10). Instantiated here so the // asset is in the synthed template; Chunk 5 handlers (Approve, // Deny, GetPolicies, CreateTask) attach the layer via @@ -301,6 +316,7 @@ export class AgentStack extends Stack { traceArtifactsBucket: traceArtifactsBucket.bucket, attachmentsBucket: attachmentsBucket.bucket, userConcurrencyTable: userConcurrencyTable.table, + agentRegistryId: agentRegistry.registryId, }); // --- AgentCore Runtime (IAM-authed orchestrator path) --- @@ -579,6 +595,16 @@ export class AgentStack extends Stack { description: 'ARN of the Secrets Manager secret for the GitHub token', }); + new CfnOutput(this, 'AgentRegistryId', { + value: agentRegistry.registryId, + description: 'ID of the AgentCore-backed agent asset registry (#246)', + }); + + new CfnOutput(this, 'AgentRegistryArn', { + value: agentRegistry.registryArn, + description: 'ARN of the AgentCore-backed agent asset registry (#246)', + }); + new CfnOutput(this, 'TraceArtifactsBucketName', { value: traceArtifactsBucket.bucket.bucketName, description: 'Name of the S3 bucket storing --trace trajectory artifacts (design §10.1)', diff --git a/cdk/test/bootstrap/__snapshots__/version.test.ts.snap b/cdk/test/bootstrap/__snapshots__/version.test.ts.snap index 71788609a..f1d42ab98 100644 --- a/cdk/test/bootstrap/__snapshots__/version.test.ts.snap +++ b/cdk/test/bootstrap/__snapshots__/version.test.ts.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing -exports[`bootstrap version module hash is stable 1`] = `"b0501c8a57f20e4b5bf50d6fe8bbb934310adc4b64480228565d8392a19d1503"`; +exports[`bootstrap version module hash is stable 1`] = `"29f5882221e3d69e107d985eb63c7ad1c239361c02adebadd57f2d32e401b076"`; diff --git a/cdk/test/bootstrap/policies.test.ts b/cdk/test/bootstrap/policies.test.ts index 2ee788c3a..89dd45ca2 100644 --- a/cdk/test/bootstrap/policies.test.ts +++ b/cdk/test/bootstrap/policies.test.ts @@ -111,6 +111,7 @@ describe('IaCRole-ABCA-Application', () => { 'WAFv2', 'EventBridge', 'SQS', + 'StepFunctions', 'CloudFront', 'SecretsManager', 'SecretsManagerAccountLevel', @@ -144,6 +145,7 @@ describe('IaCRole-ABCA-Application', () => { 'lambda', 'secretsmanager', 'sqs', + 'states', 'wafv2', ]), ); diff --git a/cdk/test/constructs/registry.test.ts b/cdk/test/constructs/registry.test.ts new file mode 100644 index 000000000..9edd411a1 --- /dev/null +++ b/cdk/test/constructs/registry.test.ts @@ -0,0 +1,102 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { App, Stack } from 'aws-cdk-lib'; +import { Template, Match } from 'aws-cdk-lib/assertions'; +import { AgentRegistry } from '../../src/constructs/registry'; + +function createStack(): Template { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + + new AgentRegistry(stack, 'AgentRegistry', { + registryName: 'abca_test', + description: 'test registry', + }); + + return Template.fromStack(stack); +} + +describe('AgentRegistry construct', () => { + test('creates onEvent and isComplete Lambda handlers plus the provider framework', () => { + const template = createStack(); + // onEvent + isComplete + the provider framework's own onEvent Lambda. + const fns = template.findResources('AWS::Lambda::Function'); + expect(Object.keys(fns).length).toBeGreaterThanOrEqual(3); + template.hasResourceProperties('AWS::Lambda::Function', { + Runtime: 'nodejs24.x', + Architectures: ['arm64'], + }); + }); + + test('registers the custom resource with the AgentCore type', () => { + const template = createStack(); + template.hasResourceProperties('Custom::AgentCoreRegistry', { + RegistryName: 'abca_test', + Description: 'test registry', + }); + }); + + test('defaults description to empty when omitted', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new AgentRegistry(stack, 'AgentRegistry', { registryName: 'abca_test' }); + const template = Template.fromStack(stack); + template.hasResourceProperties('Custom::AgentCoreRegistry', { + RegistryName: 'abca_test', + Description: '', + }); + }); + + test('grants CreateRegistry/ListRegistries on * (account-level actions)', () => { + const template = createStack(); + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Action: Match.arrayWith([ + 'bedrock-agentcore:CreateRegistry', + 'bedrock-agentcore:ListRegistries', + 'bedrock-agentcore:CreateWorkloadIdentity', + ]), + Resource: '*', + }), + ]), + }, + }); + }); + + test('grants the per-registry + record actions scoped to registry ARNs', () => { + const template = createStack(); + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Action: Match.arrayWith([ + 'bedrock-agentcore:GetRegistry', + 'bedrock-agentcore:DeleteRegistry', + 'bedrock-agentcore:ListRegistryRecords', + 'bedrock-agentcore:DeleteRegistryRecord', + ]), + }), + ]), + }, + }); + }); +}); diff --git a/cdk/test/handlers/registry-handlers.test.ts b/cdk/test/handlers/registry-handlers.test.ts new file mode 100644 index 000000000..44ca5f3e6 --- /dev/null +++ b/cdk/test/handlers/registry-handlers.test.ts @@ -0,0 +1,221 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { ConflictException } from '@aws-sdk/client-bedrock-agentcore-control'; +import type { APIGatewayProxyEvent } from 'aws-lambda'; +import { handler as listHandler } from '../../src/handlers/registry-list'; +import { handler as publishHandler } from '../../src/handlers/registry-publish'; +import { handler as resolveHandler } from '../../src/handlers/registry-resolve'; +import { handler as showHandler } from '../../src/handlers/registry-show'; +import type { RegistryClient } from '../../src/handlers/shared/registry/client'; +import { RegistryResolutionError } from '../../src/handlers/shared/registry/types'; + +// Mock the factory so handlers get our fake client (no AWS). +const mockClient: jest.Mocked = { + publish: jest.fn(), + getRecord: jest.fn(), + listRecords: jest.fn(), + resolve: jest.fn(), +}; +jest.mock('../../src/handlers/shared/registry/factory', () => { + const actual = jest.requireActual('../../src/handlers/shared/registry/factory'); + return { ...actual, makeRegistryClient: () => mockClient }; +}); +jest.mock('ulid', () => ({ ulid: jest.fn(() => 'REQ-ULID') })); + +function makeEvent(overrides: Partial = {}): APIGatewayProxyEvent { + return { + body: null, + headers: {}, + multiValueHeaders: {}, + httpMethod: 'POST', + isBase64Encoded: false, + path: '/v1/registry/records', + pathParameters: null, + queryStringParameters: null, + multiValueQueryStringParameters: null, + stageVariables: null, + resource: '/registry/records', + requestContext: { + accountId: '123456789012', + apiId: 'api-id', + authorizer: { claims: { sub: 'user-1' } }, + httpMethod: 'POST', + identity: {} as never, + path: '/v1/registry/records', + protocol: 'HTTPS', + requestId: 'gw-1', + requestTimeEpoch: 0, + resourceId: 'res', + resourcePath: '/registry/records', + stage: 'v1', + }, + ...overrides, + }; +} + +/** Build an authorizer.claims block with a cognito:groups membership. */ +function withGroups(groups: string[]): APIGatewayProxyEvent['requestContext']['authorizer'] { + return { claims: { 'sub': 'user-1', 'cognito:groups': groups.join(',') } }; +} + +const validPublishBody = { + kind: 'mcp_server', + namespace: 'acme', + name: 'pdf-tools', + asset_version: '1.0.0', + discovery: { name: 'acme/pdf-tools', description: 'd', version: '1.0.0' }, + runtime: { transport: 'http', url: 'https://x' }, +}; + +beforeEach(() => jest.clearAllMocks()); + +describe('registry-publish handler', () => { + test('401 without authentication', async () => { + const res = await publishHandler(makeEvent({ requestContext: { ...makeEvent().requestContext, authorizer: undefined } })); + expect(res.statusCode).toBe(401); + }); + + test('403 when caller is not a RegistryPublisher', async () => { + const res = await publishHandler(makeEvent({ body: JSON.stringify(validPublishBody) })); + expect(res.statusCode).toBe(403); + }); + + test('400 on invalid body (reserved kind)', async () => { + const res = await publishHandler(makeEvent({ + requestContext: { ...makeEvent().requestContext, authorizer: withGroups(['RegistryPublisher']) }, + body: JSON.stringify({ ...validPublishBody, kind: 'plugin' }), + })); + expect(res.statusCode).toBe(400); + }); + + test('400 on non-exact asset_version', async () => { + const res = await publishHandler(makeEvent({ + requestContext: { ...makeEvent().requestContext, authorizer: withGroups(['RegistryPublisher']) }, + body: JSON.stringify({ ...validPublishBody, asset_version: '^1.0.0' }), + })); + expect(res.statusCode).toBe(400); + }); + + test('403 when auto_approve without RegistryApprover', async () => { + const res = await publishHandler(makeEvent({ + requestContext: { ...makeEvent().requestContext, authorizer: withGroups(['RegistryPublisher']) }, + body: JSON.stringify({ ...validPublishBody, auto_approve: true }), + })); + expect(res.statusCode).toBe(403); + }); + + test('201 on happy path', async () => { + mockClient.publish.mockResolvedValue({ + kind: 'mcp_server', + namespace: 'acme', + name: 'pdf-tools', + version: '1.0.0', + status: 'PENDING_APPROVAL', + storageMode: 'native', + discovery: {}, + runtime: {} as never, + }); + const res = await publishHandler(makeEvent({ + requestContext: { ...makeEvent().requestContext, authorizer: withGroups(['RegistryPublisher']) }, + body: JSON.stringify(validPublishBody), + })); + expect(res.statusCode).toBe(201); + expect(JSON.parse(res.body).data.status).toBe('PENDING_APPROVAL'); + }); + + test('409 on immutability collision', async () => { + mockClient.publish.mockRejectedValue(new ConflictException({ message: 'exists', $metadata: {} })); + const res = await publishHandler(makeEvent({ + requestContext: { ...makeEvent().requestContext, authorizer: withGroups(['RegistryPublisher']) }, + body: JSON.stringify(validPublishBody), + })); + expect(res.statusCode).toBe(409); + expect(JSON.parse(res.body).error.code).toBe('REGISTRY_VERSION_EXISTS'); + }); +}); + +describe('registry-resolve handler', () => { + const ev = (ref?: string): APIGatewayProxyEvent => + makeEvent({ httpMethod: 'GET', queryStringParameters: ref ? { ref } : null }); + + test('400 when ref missing', async () => { + expect((await resolveHandler(ev())).statusCode).toBe(400); + }); + + test('422 on an invalid ref (floating constraint)', async () => { + const res = await resolveHandler(ev('registry://mcp_server/acme/pdf-tools@*')); + expect(res.statusCode).toBe(422); + expect(JSON.parse(res.body).error.message).toContain('INVALID_CONSTRAINT'); + }); + + test('200 on success', async () => { + mockClient.resolve.mockResolvedValue({ + kind: 'mcp_server', + namespace: 'acme', + name: 'pdf-tools', + version: '1.4.1', + runtime: { transport: 'http', url: 'https://x' } as never, + warnings: [], + }); + const res = await resolveHandler(ev('registry://mcp_server/acme/pdf-tools@^1.4.1')); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).data.version).toBe('1.4.1'); + }); + + test('422 when the client fails resolution', async () => { + mockClient.resolve.mockRejectedValue(new RegistryResolutionError('NO_MATCHING_VERSION', 'r', 'none')); + const res = await resolveHandler(ev('registry://mcp_server/acme/pdf-tools@^9.0.0')); + expect(res.statusCode).toBe(422); + expect(JSON.parse(res.body).error.message).toContain('NO_MATCHING_VERSION'); + }); +}); + +describe('registry-list handler', () => { + test('groups per asset at the highest version', async () => { + mockClient.listRecords.mockResolvedValue([ + { kind: 'mcp_server', namespace: 'acme', name: 'pdf-tools', version: '1.0.0', status: 'APPROVED', storageMode: 'native', discovery: {}, runtime: {} as never }, + { kind: 'mcp_server', namespace: 'acme', name: 'pdf-tools', version: '1.2.0', status: 'APPROVED', storageMode: 'native', discovery: {}, runtime: {} as never }, + ]); + const res = await listHandler(makeEvent({ httpMethod: 'GET' })); + expect(res.statusCode).toBe(200); + const assets = JSON.parse(res.body).data.assets; + expect(assets).toHaveLength(1); + expect(assets[0].latest_version).toBe('1.2.0'); + }); +}); + +describe('registry-show handler', () => { + test('404 when the asset has no versions', async () => { + mockClient.listRecords.mockResolvedValue([]); + const res = await showHandler(makeEvent({ httpMethod: 'GET', pathParameters: { kind: 'mcp_server', namespace: 'acme', name: 'nope' } })); + expect(res.statusCode).toBe(404); + }); + + test('200 lists versions highest-first', async () => { + mockClient.listRecords.mockResolvedValue([ + { kind: 'mcp_server', namespace: 'acme', name: 'pdf-tools', version: '1.0.0', status: 'DEPRECATED', storageMode: 'native', discovery: {}, runtime: {} as never }, + { kind: 'mcp_server', namespace: 'acme', name: 'pdf-tools', version: '1.2.0', status: 'APPROVED', storageMode: 'native', discovery: {}, runtime: {} as never }, + ]); + const res = await showHandler(makeEvent({ httpMethod: 'GET', pathParameters: { kind: 'mcp_server', namespace: 'acme', name: 'pdf-tools' } })); + expect(res.statusCode).toBe(200); + const versions = JSON.parse(res.body).data.versions; + expect(versions[0].version).toBe('1.2.0'); + }); +}); diff --git a/cdk/test/handlers/shared/agentcore-client.test.ts b/cdk/test/handlers/shared/agentcore-client.test.ts new file mode 100644 index 000000000..19ace09a7 --- /dev/null +++ b/cdk/test/handlers/shared/agentcore-client.test.ts @@ -0,0 +1,245 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { + CreateRegistryRecordCommand, + GetRegistryRecordCommand, + ListRegistryRecordsCommand, + SubmitRegistryRecordForApprovalCommand, + UpdateRegistryRecordStatusCommand, +} from '@aws-sdk/client-bedrock-agentcore-control'; +import { AgentCoreRegistryClient } from '../../../src/handlers/shared/registry/agentcore-client'; +import { parseRef } from '../../../src/handlers/shared/registry/ref'; +import { RegistryResolutionError } from '../../../src/handlers/shared/registry/types'; + +const RUNTIME_META_KEY = 'dev.abca.runtime'; + +/** A tiny in-memory fake of the AgentCore control-plane client. Records are + * keyed by an opaque id; List returns summaries, Get returns the full record. */ +class FakeClient { + private records = new Map>(); + private seq = 0; + public sent: string[] = []; + + seed(record: Record): string { + const id = `rec-${++this.seq}`; + this.records.set(id, { ...record, recordId: id }); + return id; + } + + async send(cmd: unknown): Promise { + if (cmd instanceof CreateRegistryRecordCommand) { + this.sent.push('create'); + const input = cmd.input as { + name?: string; + descriptorType?: string; + descriptors?: { agentSkills?: { skillMd?: { inlineContent?: string } } }; + recordVersion?: string; + }; + // Mirror the real AGENT_SKILLS validator: SKILL.md must be Markdown + // frontmatter (start with '---'), not JSON. This guards the adapter's + // skill descriptor build against regressing to JSON (the original bug). + if (input.descriptorType === 'AGENT_SKILLS') { + const md = input.descriptors?.agentSkills?.skillMd?.inlineContent ?? ''; + if (!md.startsWith('---')) { + throw new Error("agentSkills.skillMd inlineContent must start with frontmatter delimited by '---'"); + } + } + const id = `rec-${++this.seq}`; + this.records.set(id, { + recordId: id, + recordArn: `arn:aws:bedrock-agentcore:us-east-1:1:registry/r/record/${id}`, + name: input.name, + descriptorType: input.descriptorType, + descriptors: input.descriptors, + recordVersion: input.recordVersion, + status: 'CREATING', + }); + return { recordArn: `arn:aws:bedrock-agentcore:us-east-1:1:registry/r/record/${id}`, status: 'CREATING' }; + } + if (cmd instanceof GetRegistryRecordCommand) { + const id = (cmd.input as { recordId: string }).recordId; + const rec = this.records.get(id); + // Simulate async settle: first Get after create flips CREATING → DRAFT. + if (rec && rec.status === 'CREATING') rec.status = 'DRAFT'; + return rec ?? {}; + } + if (cmd instanceof ListRegistryRecordsCommand) { + return { registryRecords: [...this.records.values()], nextToken: undefined }; + } + if (cmd instanceof SubmitRegistryRecordForApprovalCommand) { + this.sent.push('submit'); + const id = (cmd.input as { recordId: string }).recordId; + const rec = this.records.get(id); + if (rec) rec.status = 'PENDING_APPROVAL'; + return { status: 'PENDING_APPROVAL' }; + } + if (cmd instanceof UpdateRegistryRecordStatusCommand) { + this.sent.push('approve'); + const { recordId, status } = cmd.input as { recordId: string; status: string }; + const rec = this.records.get(recordId); + if (rec) rec.status = status; + return { status }; + } + throw new Error(`unexpected command ${cmd?.constructor?.name}`); + } +} + +function makeClient(fake: FakeClient): AgentCoreRegistryClient { + return new AgentCoreRegistryClient({ + registryId: 'r', + client: fake as never, + }); +} + +describe('AgentCoreRegistryClient', () => { + test('publish (native + autoApprove) drives create → submit → approve and embeds _meta', async () => { + const fake = new FakeClient(); + const client = makeClient(fake); + const runtime = { transport: 'http' as const, url: 'https://x/sse', tool_prefix: 'mcp__x__' }; + + const record = await client.publish({ + kind: 'mcp_server', + namespace: 'acme', + name: 'pdf-tools', + version: '1.0.0', + discovery: { name: 'acme/pdf-tools', description: 'd', version: '1.0.0' }, + runtime, + autoApprove: true, + }); + + expect(fake.sent).toEqual(['create', 'submit', 'approve']); + expect(record.status).toBe('APPROVED'); + expect(record.storageMode).toBe('native'); + expect(record.runtime).toEqual(runtime); + // discovery body carried the runtime under _meta (spike-verified shape) + expect((record.discovery as Record)._meta).toMatchObject({ [RUNTIME_META_KEY]: runtime }); + }); + + test('publish (custom) round-trips runtime verbatim in the CUSTOM body', async () => { + const fake = new FakeClient(); + const client = makeClient(fake); + const runtime = { cedar_text: 'permit(principal, action, resource);' }; + const record = await client.publish({ + kind: 'cedar_policy_module', + namespace: 'acme', + name: 'permit-all', + version: '1.0.0', + discovery: { summary: 's' }, + runtime, + }); + expect(record.storageMode).toBe('custom'); + expect(record.runtime).toEqual(runtime); + }); + + test('publish (native skill) emits valid SKILL.md frontmatter + round-trips runtime', async () => { + const fake = new FakeClient(); + const client = makeClient(fake); + const runtime = { prompt_fragment: 'Always add a trailing note when editing.', tool_hints: ['Edit'] }; + // Would throw in FakeClient if the adapter emitted JSON instead of frontmatter. + const record = await client.publish({ + kind: 'skill', + namespace: 'acme', + name: 'readme-helper', + version: '1.0.0', + discovery: { description: 'Appends a note when editing files' }, + runtime, + autoApprove: true, + }); + expect(record.status).toBe('APPROVED'); + expect(record.storageMode).toBe('native'); + expect(record.runtime).toEqual(runtime); + }); + + test('publish rejects a duplicate (kind,namespace,name,version)', async () => { + const fake = new FakeClient(); + const client = makeClient(fake); + const input = { + kind: 'mcp_server', + namespace: 'acme', + name: 'pdf-tools', + version: '1.0.0', + discovery: { name: 'acme/pdf-tools', description: 'd', version: '1.0.0' }, + runtime: { transport: 'http' as const, url: 'https://x' }, + autoApprove: true, + }; + await client.publish(input); + await expect(client.publish(input)).rejects.toThrow(); + }); + + test('resolve picks the highest APPROVED version matching the constraint', async () => { + const fake = new FakeClient(); + const client = makeClient(fake); + const seedMcp = (version: string, status: string): void => { + fake.seed({ + name: 'mcp_server/acme/pdf-tools', + descriptorType: 'MCP', + descriptors: { mcp: { server: { inlineContent: JSON.stringify({ name: 'acme/pdf-tools', version, _meta: { [RUNTIME_META_KEY]: { transport: 'http', url: `https://x/${version}` } } }) } } }, + recordVersion: version, + status, + }); + }; + seedMcp('1.4.1', 'APPROVED'); + seedMcp('1.9.9', 'APPROVED'); + seedMcp('2.0.0', 'APPROVED'); + seedMcp('1.9.10', 'DRAFT'); // higher but not approved → excluded + + const parsed = parseRef('registry://mcp_server/acme/pdf-tools@^1.4.1'); + if (!parsed.ok) throw new Error('fixture ref should parse'); + const asset = await client.resolve(parsed.ref); + expect(asset.version).toBe('1.9.9'); + expect(asset.warnings).toEqual([]); + expect(asset.runtime).toMatchObject({ url: 'https://x/1.9.9' }); + }); + + test('resolve warns on a DEPRECATED winner', async () => { + const fake = new FakeClient(); + const client = makeClient(fake); + fake.seed({ + name: 'mcp_server/acme/pdf-tools', + descriptorType: 'MCP', + descriptors: { mcp: { server: { inlineContent: JSON.stringify({ name: 'acme/pdf-tools', version: '1.4.1', _meta: { [RUNTIME_META_KEY]: { transport: 'http' } } }) } } }, + recordVersion: '1.4.1', + status: 'DEPRECATED', + }); + const parsed = parseRef('registry://mcp_server/acme/pdf-tools@1.4.1'); + if (!parsed.ok) throw new Error('fixture ref should parse'); + const asset = await client.resolve(parsed.ref); + expect(asset.version).toBe('1.4.1'); + expect(asset.warnings).toEqual(['DEPRECATED']); + }); + + test('resolve fails NO_MATCHING_VERSION when only non-candidate statuses exist', async () => { + const fake = new FakeClient(); + const client = makeClient(fake); + fake.seed({ + name: 'mcp_server/acme/pdf-tools', + descriptorType: 'MCP', + descriptors: { mcp: { server: { inlineContent: JSON.stringify({ name: 'acme/pdf-tools', version: '1.4.1' }) } } }, + recordVersion: '1.4.1', + status: 'PENDING_APPROVAL', + }); + const parsed = parseRef('registry://mcp_server/acme/pdf-tools@^1.4.1'); + if (!parsed.ok) throw new Error('fixture ref should parse'); + await expect(client.resolve(parsed.ref)).rejects.toMatchObject({ + reason: 'NO_MATCHING_VERSION', + }); + await expect(client.resolve(parsed.ref)).rejects.toBeInstanceOf(RegistryResolutionError); + }); +}); diff --git a/cdk/test/handlers/shared/registry-resolution-parity.test.ts b/cdk/test/handlers/shared/registry-resolution-parity.test.ts new file mode 100644 index 000000000..d2aa155c2 --- /dev/null +++ b/cdk/test/handlers/shared/registry-resolution-parity.test.ts @@ -0,0 +1,96 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Grammar parity corpus runner (TypeScript side) for registry:// refs (#246). + * + * Loads ``contracts/registry-resolution/cases.json`` and asserts ``parseRef`` + * agrees with each golden verdict. The companion runner + * ``agent/tests/test_registry_resolution_corpus.py`` runs the same file against + * the Python ``parse_ref``; if either side disagrees, CI fails before deploy. + * Mirrors the cedar-parity dual-runner pattern. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { parseRef } from '../../../src/handlers/shared/registry/ref'; + +const CASES_FILE = path.resolve( + __dirname, + '..', + '..', + '..', + '..', + 'contracts', + 'registry-resolution', + 'cases.json', +); + +interface ExpectedOk { + ok: true; + kind: string; + namespace: string; + name: string; + op: string; + major: number; + minor: number; + patch: number; + prerelease: string | null; +} +interface ExpectedErr { + ok: false; + reason: string; +} +interface Case { + name: string; + ref: string; + expected: ExpectedOk | ExpectedErr; +} + +const corpus = JSON.parse(fs.readFileSync(CASES_FILE, 'utf-8')) as { cases: Case[] }; + +describe('registry:// grammar parity corpus (TS parseRef)', () => { + test('corpus is present and non-empty', () => { + expect(corpus.cases.length).toBeGreaterThan(0); + }); + + for (const c of corpus.cases) { + test(c.name, () => { + const result = parseRef(c.ref); + if (!c.expected.ok) { + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toBe(c.expected.reason); + } + return; + } + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.ref.kind).toBe(c.expected.kind); + expect(result.ref.namespace).toBe(c.expected.namespace); + expect(result.ref.name).toBe(c.expected.name); + expect(result.ref.constraint.op).toBe(c.expected.op); + expect(result.ref.constraint.major).toBe(c.expected.major); + expect(result.ref.constraint.minor).toBe(c.expected.minor); + expect(result.ref.constraint.patch).toBe(c.expected.patch); + expect(result.ref.constraint.prerelease ?? null).toBe(c.expected.prerelease); + } + }); + } +}); diff --git a/cdk/test/handlers/shared/registry-resolver.test.ts b/cdk/test/handlers/shared/registry-resolver.test.ts new file mode 100644 index 000000000..83180f0fe --- /dev/null +++ b/cdk/test/handlers/shared/registry-resolver.test.ts @@ -0,0 +1,93 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { parseConstraint } from '../../../src/handlers/shared/registry/ref'; +import { + compareVersions, + parseVersion, + satisfies, + selectHighest, +} from '../../../src/handlers/shared/registry/resolver'; + +const C = (s: string) => parseConstraint(s)!; +const V = (s: string) => parseVersion(s)!; + +describe('registry resolver — parseVersion', () => { + test('parses core + prerelease', () => { + expect(parseVersion('1.4.1')).toMatchObject({ major: 1, minor: 4, patch: 1, prerelease: [] }); + expect(parseVersion('2.0.0-rc.1')).toMatchObject({ major: 2, minor: 0, patch: 0, prerelease: ['rc', '1'] }); + }); + test('rejects non-semver + leading zeros', () => { + expect(parseVersion('1.4')).toBeNull(); + expect(parseVersion('01.0.0')).toBeNull(); + expect(parseVersion('latest')).toBeNull(); + }); +}); + +describe('registry resolver — compareVersions', () => { + test('orders by core then prerelease', () => { + expect(compareVersions(V('1.0.0'), V('2.0.0'))).toBeLessThan(0); + expect(compareVersions(V('1.2.0'), V('1.1.9'))).toBeGreaterThan(0); + expect(compareVersions(V('1.0.0'), V('1.0.0'))).toBe(0); + }); + test('prerelease ranks below its release', () => { + expect(compareVersions(V('1.4.1-rc.1'), V('1.4.1'))).toBeLessThan(0); + expect(compareVersions(V('1.4.1-rc.1'), V('1.4.1-rc.2'))).toBeLessThan(0); + expect(compareVersions(V('1.4.1-rc.2'), V('1.4.1-rc.10'))).toBeLessThan(0); + }); +}); + +describe('registry resolver — satisfies', () => { + test('exact matches only the exact version incl. prerelease', () => { + expect(satisfies(V('1.4.1'), C('1.4.1'))).toBe(true); + expect(satisfies(V('1.4.2'), C('1.4.1'))).toBe(false); + expect(satisfies(V('1.4.1'), C('1.4.1-rc.1'))).toBe(false); + expect(satisfies(V('1.4.1-rc.1'), C('1.4.1-rc.1'))).toBe(true); + }); + test('caret stays within the major', () => { + expect(satisfies(V('1.9.9'), C('^1.4.1'))).toBe(true); + expect(satisfies(V('1.4.0'), C('^1.4.1'))).toBe(false); + expect(satisfies(V('2.0.0'), C('^1.4.1'))).toBe(false); + }); + test('caret ^0.x stays within the minor', () => { + expect(satisfies(V('0.2.9'), C('^0.2.0'))).toBe(true); + expect(satisfies(V('0.3.0'), C('^0.2.0'))).toBe(false); + }); + test('tilde stays within the minor', () => { + expect(satisfies(V('1.4.9'), C('~1.4.1'))).toBe(true); + expect(satisfies(V('1.5.0'), C('~1.4.1'))).toBe(false); + }); + test('prereleases excluded from range matches', () => { + expect(satisfies(V('1.5.0-rc.1'), C('^1.4.1'))).toBe(false); + }); +}); + +describe('registry resolver — selectHighest', () => { + test('picks the highest in-range version', () => { + expect(selectHighest(['1.4.1', '1.5.0', '1.9.9', '2.0.0'], C('^1.4.1'))).toBe('1.9.9'); + expect(selectHighest(['1.4.1', '1.4.9', '1.5.0'], C('~1.4.1'))).toBe('1.4.9'); + }); + test('returns null when nothing matches', () => { + expect(selectHighest(['2.0.0', '3.0.0'], C('^1.0.0'))).toBeNull(); + expect(selectHighest([], C('1.0.0'))).toBeNull(); + }); + test('skips unparseable candidate strings', () => { + expect(selectHighest(['garbage', '1.0.0', 'also-bad'], C('^1.0.0'))).toBe('1.0.0'); + }); +}); diff --git a/cli/src/api-client.ts b/cli/src/api-client.ts index c48b1be53..a82779c13 100644 --- a/cli/src/api-client.ts +++ b/cli/src/api-client.ts @@ -42,6 +42,11 @@ import { LinearLinkResponse, NudgeRequest, NudgeResponse, + RegistryListEntry, + RegistryPublishRequest, + RegistryRecordResponse, + RegistryResolveResponse, + RegistryVersionSummary, SlackLinkResponse, PaginatedResponse, ReplayBundle, @@ -510,4 +515,49 @@ export class ApiClient { const res = await this.request>('POST', '/jira/link', body); return res.data; } + + // --- Agent asset registry (#246) --- + + /** POST /registry/records — publish an asset record. */ + async registryPublish(req: RegistryPublishRequest): Promise { + const res = await this.request>('POST', '/registry/records', req); + return res.data; + } + + /** GET /registry/resolve?ref=… — resolve a pinned ref to a single asset. */ + async registryResolve(ref: string): Promise { + const res = await this.request>( + 'GET', + `/registry/resolve?ref=${encodeURIComponent(ref)}`, + ); + return res.data; + } + + /** GET /registry/records — list assets (optionally filtered). */ + async registryList(opts?: { kind?: string; namespace?: string }): Promise { + const params = new URLSearchParams(); + if (opts?.kind) params.set('kind', opts.kind); + if (opts?.namespace) params.set('namespace', opts.namespace); + const qs = params.toString(); + const res = await this.request>( + 'GET', + `/registry/records${qs ? `?${qs}` : ''}`, + ); + return res.data.assets; + } + + /** GET /registry/records/{kind}/{namespace}/{name} — show all versions. */ + async registryShow( + kind: string, + namespace: string, + name: string, + ): Promise<{ kind: string; namespace: string; name: string; versions: RegistryVersionSummary[] }> { + const res = await this.request< + SuccessResponse<{ kind: string; namespace: string; name: string; versions: RegistryVersionSummary[] }> + >( + 'GET', + `/registry/records/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`, + ); + return res.data; + } } diff --git a/cli/src/bin/bgagent.ts b/cli/src/bin/bgagent.ts index 47a797cd7..c2c4dca83 100644 --- a/cli/src/bin/bgagent.ts +++ b/cli/src/bin/bgagent.ts @@ -37,6 +37,7 @@ import { makeOpsCommand } from '../commands/ops'; import { makePendingCommand } from '../commands/pending'; import { makePlatformCommand } from '../commands/platform'; import { makePoliciesCommand } from '../commands/policies'; +import { makeRegistryCommand } from '../commands/registry'; import { makeReplayCommand } from '../commands/replay'; import { makeRepoCommand } from '../commands/repo'; import { makeRuntimeCommand } from '../commands/runtime'; @@ -90,6 +91,7 @@ program.addCommand(makeTraceCommand()); program.addCommand(makeWebhookCommand()); program.addCommand(makeApiKeyCommand()); program.addCommand(makeAdminCommand()); +program.addCommand(makeRegistryCommand()); // Execute the CLI only when run directly. Importing this module (e.g. // from a test harness or a wrapper) must not parse the importer's diff --git a/cli/src/commands/registry.ts b/cli/src/commands/registry.ts new file mode 100644 index 000000000..c1561ed09 --- /dev/null +++ b/cli/src/commands/registry.ts @@ -0,0 +1,157 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as fs from 'fs'; +import { Command } from 'commander'; +import { ApiClient } from '../api-client'; +import { CliError } from '../errors'; +import type { RegistryPublishRequest } from '../types'; + +const KIND_WIDTH = 20; +const NS_WIDTH = 16; +const NAME_WIDTH = 24; +const VERSION_WIDTH = 12; + +/** Read + parse a JSON file argument, failing with a friendly CliError. */ +function readJsonFile(label: string, filePath: string): Record { + let raw: string; + try { + raw = fs.readFileSync(filePath, 'utf-8'); + } catch { + throw new CliError(`Cannot read ${label} file: ${filePath}`); + } + try { + return JSON.parse(raw) as Record; + } catch { + throw new CliError(`${label} file is not valid JSON: ${filePath}`); + } +} + +export function makeRegistryCommand(): Command { + const registry = new Command('registry').description('Agent asset registry (#246)'); + + registry.addCommand( + new Command('publish') + .description('Publish an asset record (requires the RegistryPublisher group)') + .requiredOption('--kind ', 'Asset kind (mcp_server | cedar_policy_module | skill)') + .requiredOption('--namespace ', 'Owner namespace') + .requiredOption('--name ', 'Asset name') + // NOT --version: commander reserves that for the program version flag. + .requiredOption('--asset-version ', 'Exact semver, e.g. 1.4.1') + .requiredOption('--discovery ', 'Path to a JSON file with the discovery descriptor') + .requiredOption('--runtime ', 'Path to a JSON file with the ABCA runtime payload') + .option('--custom', 'Store as a verbatim CUSTOM record instead of a native descriptor', false) + .option('--auto-approve', 'Drive the record to APPROVED (requires RegistryApprover)', false) + .option('--output ', 'Output format: text or json', 'text') + .action(async (opts) => { + const req: RegistryPublishRequest = { + kind: opts.kind, + namespace: opts.namespace, + name: opts.name, + asset_version: opts.assetVersion, + discovery: readJsonFile('discovery', opts.discovery), + runtime: readJsonFile('runtime', opts.runtime), + custom: opts.custom, + auto_approve: opts.autoApprove, + }; + const record = await new ApiClient().registryPublish(req); + if (opts.output === 'json') { + console.log(JSON.stringify(record, null, 2)); + return; + } + console.log( + `Published ${record.kind}/${record.namespace}/${record.name}@${record.version} ` + + `(status: ${record.status}, storage: ${record.storage_mode})`, + ); + }), + ); + + registry.addCommand( + new Command('resolve') + .description('Resolve a registry:// ref to a single asset') + .argument('', 'registry://kind/namespace/name@constraint') + .option('--output ', 'Output format: text or json', 'text') + .action(async (ref: string, opts) => { + const asset = await new ApiClient().registryResolve(ref); + if (opts.output === 'json') { + console.log(JSON.stringify(asset, null, 2)); + return; + } + console.log(`${asset.kind}/${asset.namespace}/${asset.name}@${asset.version}`); + if (asset.warnings.length > 0) { + console.log(` warnings: ${asset.warnings.join(', ')}`); + } + console.log(` runtime: ${JSON.stringify(asset.runtime)}`); + }), + ); + + registry.addCommand( + new Command('list') + .description('List assets (optionally filtered by kind/namespace)') + .option('--kind ', 'Filter by kind') + .option('--namespace ', 'Filter by namespace') + .option('--output ', 'Output format: text or json', 'text') + .action(async (opts) => { + const assets = await new ApiClient().registryList({ kind: opts.kind, namespace: opts.namespace }); + if (opts.output === 'json') { + console.log(JSON.stringify({ assets }, null, 2)); + return; + } + if (assets.length === 0) { + console.log('No assets found.'); + return; + } + console.log( + `${'KIND'.padEnd(KIND_WIDTH)} ${'NAMESPACE'.padEnd(NS_WIDTH)} ` + + `${'NAME'.padEnd(NAME_WIDTH)} ${'LATEST'.padEnd(VERSION_WIDTH)} STATUS`, + ); + for (const a of assets) { + console.log( + `${a.kind.padEnd(KIND_WIDTH)} ${a.namespace.padEnd(NS_WIDTH)} ` + + `${a.name.padEnd(NAME_WIDTH)} ${(a.latest_version ?? '-').padEnd(VERSION_WIDTH)} ${a.status}`, + ); + } + }), + ); + + registry.addCommand( + new Command('show') + .description('Show every version of one asset') + .argument('', 'Asset kind') + .argument('', 'Owner namespace') + .argument('', 'Asset name') + .option('--output ', 'Output format: text or json', 'text') + .action(async (kind: string, namespace: string, name: string, opts) => { + const result = await new ApiClient().registryShow(kind, namespace, name); + if (opts.output === 'json') { + console.log(JSON.stringify(result, null, 2)); + return; + } + console.log(`${result.kind}/${result.namespace}/${result.name}`); + console.log(`${'VERSION'.padEnd(VERSION_WIDTH)} ${'STATUS'.padEnd(NS_WIDTH)} CREATED`); + for (const v of result.versions) { + console.log( + `${v.version.padEnd(VERSION_WIDTH)} ${v.status.padEnd(NS_WIDTH)} ${v.created_at ?? '-'}`, + ); + } + }), + ); + + return registry; +} diff --git a/cli/src/types.ts b/cli/src/types.ts index 30958bfda..006d824cd 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -30,6 +30,73 @@ export type ResolvedWorkflow = { readonly version: string; }; +/** + * A resolved registry-asset pin stamped on the TaskRecord for audit (#246). + * Mirrors ``cdk/src/handlers/shared/types.ts::ResolvedAssetTriple``. + */ +export type ResolvedAssetTriple = { + readonly kind: string; + readonly id: string; + readonly version: string; +}; + +// --- Agent asset registry (#246) API wire types ------------------------------ +// Mirrors ``cdk/src/handlers/shared/types.ts`` per the CLI types-sync contract. + +/** `POST /registry/records` request body. */ +export type RegistryPublishRequest = { + readonly kind: string; + readonly namespace: string; + readonly name: string; + /** semver string, immutable once written. */ + readonly asset_version: string; + /** discovery descriptor body (server.json / SKILL.md / arbitrary JSON). */ + readonly discovery: Record; + /** ABCA runtime payload (connection config / cedar text / prompt fragment). */ + readonly runtime: Record; + /** force CUSTOM (verbatim) storage instead of a native descriptor. */ + readonly custom?: boolean; + /** dev convenience: drive create→submit→approve so the record resolves. */ + readonly auto_approve?: boolean; +}; + +/** One version row in a `show` response. */ +export type RegistryVersionSummary = { + readonly version: string; + readonly status: string; + readonly created_at: string | null; + readonly publisher: string | null; +}; + +/** A record envelope returned by publish / show. */ +export type RegistryRecordResponse = { + readonly kind: string; + readonly namespace: string; + readonly name: string; + readonly version: string; + readonly status: string; + readonly storage_mode: string; +}; + +/** `GET /registry/resolve?ref=…` response. */ +export type RegistryResolveResponse = { + readonly kind: string; + readonly namespace: string; + readonly name: string; + readonly version: string; + readonly runtime: Record; + readonly warnings: readonly string[]; +}; + +/** One asset row in a `list` response. */ +export type RegistryListEntry = { + readonly kind: string; + readonly namespace: string; + readonly name: string; + readonly latest_version: string | null; + readonly status: string; +}; + /** Shared across all attachment interfaces. Add new types here (e.g., 'audio'). */ export type AttachmentType = 'image' | 'file' | 'url'; @@ -90,6 +157,8 @@ export interface TaskDetail { readonly repo: string | null; readonly issue_number: number | null; readonly resolved_workflow: ResolvedWorkflow | null; + /** Registry assets resolved for this task (#246); null when none pinned. */ + readonly resolved_assets: ResolvedAssetTriple[] | null; readonly pr_number: number | null; readonly task_description: string | null; readonly branch_name: string; @@ -231,6 +300,8 @@ export interface TaskSummary { readonly repo: string | null; readonly issue_number: number | null; readonly resolved_workflow: ResolvedWorkflow | null; + /** Registry assets resolved for this task (#246); null when none pinned. */ + readonly resolved_assets: ResolvedAssetTriple[] | null; readonly pr_number: number | null; readonly task_description: string | null; readonly branch_name: string; diff --git a/contracts/registry-resolution/README.md b/contracts/registry-resolution/README.md new file mode 100644 index 000000000..c8c1cb670 --- /dev/null +++ b/contracts/registry-resolution/README.md @@ -0,0 +1,68 @@ +# registry:// grammar parity fixtures (#246) + +Golden `(ref) → verdict` vectors shared by the two `registry://` reference +parsers: + +- **Agent (Python):** [`agent/tests/test_registry_resolution_corpus.py`](../../agent/tests/test_registry_resolution_corpus.py) + runs each `ref` through `registry.ref.parse_ref`. +- **CDK (TypeScript):** [`cdk/test/handlers/shared/registry-resolution-parity.test.ts`](../../cdk/test/handlers/shared/registry-resolution-parity.test.ts) + runs the same refs through `parseRef`. + +Both parsers implement the identical grammar in different languages; this corpus +is how we catch drift before deploy — the same mechanism +[`contracts/cedar-parity/`](../cedar-parity/README.md) uses for the two Cedar +engines and [`contracts/workflow-validation/`](../workflow-validation/README.md) +anticipates for the publish-path validator. + +## Why this lives in `contracts/` + +The `registry://` grammar is enforced on both sides of the platform: the +orchestrator/handlers (TS) validate refs at publish and resolve time, and the +agent (Python) parses resolved refs at load time. Neither `agent/` nor `cdk/` +owns the grammar — it is an agreement *between* them, so it lives in this neutral +directory both test suites reach into. + +## Fixture shape + +A single `cases.json` with a `cases` array. Each case: + +```jsonc +{ + "name": "short-identifier", + "ref": "registry://kind/namespace/name@constraint", + "expected": { + // success: + "ok": true, + "kind": "mcp_server", "namespace": "acme", "name": "pdf-tools", + "op": "exact", // exact | caret | tilde + "major": 1, "minor": 4, "patch": 1, + "prerelease": null // or the tag without the leading '-' + // failure (instead of the above): + // "ok": false, "reason": "INVALID_REGISTRY_REF" | "INVALID_CONSTRAINT" + } +} +``` + +Both parsers must agree on `ok`, on `reason` when `ok:false`, and on every parsed +field when `ok:true`. + +## Grammar (authoritative) + +``` +registry:////@ + kind = [a-z][a-z0-9_]* snake_case: mcp_server, cedar_policy_module + namespace = [a-z][a-z0-9-]* + name = [a-z0-9][a-z0-9._-]* + constraint = [^~]?MAJOR.MINOR.PATCH[-prerelease] exact / caret / tilde only +``` + +The `@` pin is **mandatory** (fail-closed — no implicit "latest"). +Floating constraints (`*`, `latest`, `>=`, `<=`, x-ranges, partial versions) are +rejected with `INVALID_CONSTRAINT`; anything that does not match the ref shape is +`INVALID_REGISTRY_REF`. + +> **Note.** The workflow validator's `_REGISTRY_REF` (in `agent/src/workflow/ +> validator.py`) is a deliberately *looser* acceptance check that also admits the +> legacy 2-segment illustrative form used by `contracts/workflow-validation/`. +> This corpus pins the **strict** #246 grammar (`parse_ref` / `parseRef`), which +> is what resolution enforces. diff --git a/contracts/registry-resolution/cases.json b/contracts/registry-resolution/cases.json new file mode 100644 index 000000000..6c240a68a --- /dev/null +++ b/contracts/registry-resolution/cases.json @@ -0,0 +1,95 @@ +{ + "description": "Grammar parity corpus for the registry:// reference syntax (#246). Each case is run against BOTH the Python parser (agent/src/registry/ref.py::parse_ref) and the TypeScript parser (cdk/src/handlers/shared/registry/ref.ts::parseRef). Both must agree on ok/reason and, when ok, on the parsed fields. See README.md.", + "cases": [ + { + "name": "exact-pin", + "ref": "registry://mcp_server/acme/pdf-tools@1.4.1", + "expected": { "ok": true, "kind": "mcp_server", "namespace": "acme", "name": "pdf-tools", "op": "exact", "major": 1, "minor": 4, "patch": 1, "prerelease": null } + }, + { + "name": "caret-pin", + "ref": "registry://mcp_server/acme/pdf-tools@^1.4.1", + "expected": { "ok": true, "kind": "mcp_server", "namespace": "acme", "name": "pdf-tools", "op": "caret", "major": 1, "minor": 4, "patch": 1, "prerelease": null } + }, + { + "name": "tilde-pin", + "ref": "registry://cedar_policy_module/acme/force-push@~2.0.3", + "expected": { "ok": true, "kind": "cedar_policy_module", "namespace": "acme", "name": "force-push", "op": "tilde", "major": 2, "minor": 0, "patch": 3, "prerelease": null } + }, + { + "name": "exact-prerelease", + "ref": "registry://skill/acme/research@1.0.0-rc.1", + "expected": { "ok": true, "kind": "skill", "namespace": "acme", "name": "research", "op": "exact", "major": 1, "minor": 0, "patch": 0, "prerelease": "rc.1" } + }, + { + "name": "name-with-dots-and-dashes", + "ref": "registry://mcp_server/acme/pdf.tools-v2@1.0.0", + "expected": { "ok": true, "kind": "mcp_server", "namespace": "acme", "name": "pdf.tools-v2", "op": "exact", "major": 1, "minor": 0, "patch": 0, "prerelease": null } + }, + { + "name": "snake-case-kind", + "ref": "registry://cedar_policy_module/team-security/deny-all@0.1.0", + "expected": { "ok": true, "kind": "cedar_policy_module", "namespace": "team-security", "name": "deny-all", "op": "exact", "major": 0, "minor": 1, "patch": 0, "prerelease": null } + }, + { + "name": "missing-constraint", + "ref": "registry://mcp_server/acme/pdf-tools", + "expected": { "ok": false, "reason": "INVALID_REGISTRY_REF" } + }, + { + "name": "wildcard-constraint", + "ref": "registry://mcp_server/acme/pdf-tools@*", + "expected": { "ok": false, "reason": "INVALID_CONSTRAINT" } + }, + { + "name": "latest-constraint", + "ref": "registry://mcp_server/acme/pdf-tools@latest", + "expected": { "ok": false, "reason": "INVALID_CONSTRAINT" } + }, + { + "name": "gte-range-constraint", + "ref": "registry://mcp_server/acme/pdf-tools@>=1.0.0", + "expected": { "ok": false, "reason": "INVALID_CONSTRAINT" } + }, + { + "name": "x-range-constraint", + "ref": "registry://mcp_server/acme/pdf-tools@1.x", + "expected": { "ok": false, "reason": "INVALID_CONSTRAINT" } + }, + { + "name": "partial-version-constraint", + "ref": "registry://mcp_server/acme/pdf-tools@1.4", + "expected": { "ok": false, "reason": "INVALID_CONSTRAINT" } + }, + { + "name": "uppercase-kind-rejected", + "ref": "registry://McpServer/acme/pdf-tools@1.0.0", + "expected": { "ok": false, "reason": "INVALID_REGISTRY_REF" } + }, + { + "name": "two-segment-rejected-by-strict", + "ref": "registry://mcp/web-search@1.0.0", + "expected": { "ok": false, "reason": "INVALID_REGISTRY_REF" } + }, + { + "name": "wrong-scheme", + "ref": "https://mcp_server/acme/pdf-tools@1.0.0", + "expected": { "ok": false, "reason": "INVALID_REGISTRY_REF" } + }, + { + "name": "namespace-leading-digit-rejected", + "ref": "registry://mcp_server/9acme/pdf-tools@1.0.0", + "expected": { "ok": false, "reason": "INVALID_REGISTRY_REF" } + }, + { + "name": "leading-zero-major-rejected", + "ref": "registry://mcp_server/acme/pdf-tools@01.0.0", + "expected": { "ok": false, "reason": "INVALID_CONSTRAINT" } + }, + { + "name": "caret-zero-minor", + "ref": "registry://mcp_server/acme/pdf-tools@^0.2.0", + "expected": { "ok": true, "kind": "mcp_server", "namespace": "acme", "name": "pdf-tools", "op": "caret", "major": 0, "minor": 2, "patch": 0, "prerelease": null } + } + ] +} diff --git a/docs/design/DEPLOYMENT_ROLES.md b/docs/design/DEPLOYMENT_ROLES.md index 9f2f0baca..167011648 100644 --- a/docs/design/DEPLOYMENT_ROLES.md +++ b/docs/design/DEPLOYMENT_ROLES.md @@ -115,6 +115,7 @@ CloudFormation stack operations, IAM roles/policies, VPC networking, and Route 5 ], "Resource": [ "arn:aws:cloudformation:*:*:stack/backgroundagent-dev/*", + "arn:aws:cloudformation:*:*:stack/backgroundagent-dev-*", "arn:aws:cloudformation:*:*:stack/CDKToolkit/*" ] }, @@ -170,6 +171,7 @@ CloudFormation stack operations, IAM roles/policies, VPC networking, and Route 5 "bedrock.amazonaws.com", "bedrock-agentcore.amazonaws.com", "events.amazonaws.com", + "states.amazonaws.com", "vpc-flow-logs.amazonaws.com" ] } @@ -391,6 +393,10 @@ DynamoDB tables, Lambda functions, API Gateway, Cognito, WAFv2, EventBridge, SQS "cognito-idp:DeleteUserPoolClient", "cognito-idp:DescribeUserPoolClient", "cognito-idp:UpdateUserPoolClient", + "cognito-idp:CreateGroup", + "cognito-idp:DeleteGroup", + "cognito-idp:GetGroup", + "cognito-idp:UpdateGroup", "cognito-idp:TagResource", "cognito-idp:UntagResource", "cognito-idp:ListTagsForResource", @@ -449,6 +455,20 @@ DynamoDB tables, Lambda functions, API Gateway, Cognito, WAFv2, EventBridge, SQS ], "Resource": "arn:aws:sqs:*:*:backgroundagent-dev-*" }, + { + "Sid": "StepFunctions", + "Effect": "Allow", + "Action": [ + "states:CreateStateMachine", + "states:DeleteStateMachine", + "states:DescribeStateMachine", + "states:UpdateStateMachine", + "states:TagResource", + "states:UntagResource", + "states:ListTagsForResource" + ], + "Resource": "arn:aws:states:*:*:stateMachine:backgroundagent-dev-*" + }, { "Sid": "CloudFront", "Effect": "Allow", diff --git a/docs/design/REGISTRY.md b/docs/design/REGISTRY.md new file mode 100644 index 000000000..9f22b88b4 --- /dev/null +++ b/docs/design/REGISTRY.md @@ -0,0 +1,193 @@ +# Agent asset registry + +A **registry asset** is a versioned, immutable-per-version runtime artifact that a task can load — an MCP server, a Cedar policy module, or a skill. Today those artifacts are vendored into the container image (`agent/src/channel_mcp.py`), inlined on the Blueprint construct (Cedar policies), or committed to a repo (`.mcp.json`). None of them are versioned, none carry an audit trail, and adding one means a **core-code change plus a CDK deploy**. The registry replaces that with a catalog: publishers push typed, versioned records via an API; blueprints pin them by `registry://kind/namespace/name@constraint`; the orchestrator resolves the pins at task start; and the agent receives a resolved bundle. + +- **Use this doc for:** the asset-kind catalog, the substrate mapping (AgentCore descriptor types + the `_meta` runtime convention), the publish/resolve/list/show API contract, resolution semantics (semver, immutability, status), governance (the approval state machine), and how a resolved bundle flows from orchestrator to agent. +- **Related docs:** [WORKFLOWS.md](./WORKFLOWS.md) for the `registry://` grammar and asset-kind vocabulary, [REPO_ONBOARDING.md](./REPO_ONBOARDING.md) for the per-repo **Blueprint** that references assets, [CEDAR_HITL_GATES.md](./CEDAR_HITL_GATES.md) for the policy engine that consumes `cedar_policy_module` assets, [SECURITY.md](./SECURITY.md) for tool tiers, and [IDENTITY_AND_AUTH.md](./IDENTITY_AND_AUTH.md) for the Cognito groups that gate publish. +- **Tracking issue:** [#246](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/246). + +> **Substrate: AWS Agent Registry (Bedrock AgentCore).** The registry is built on the managed AgentCore Registry (public preview), not a first-party DynamoDB+S3 store. AgentCore natively provides typed descriptor validation, a governance state machine, hybrid search, and audit — so ABCA builds only the substrate-agnostic parts (grammar, semver resolution, orchestrator/agent integration) plus one adapter. A design-time spike validated this substrate (async provisioning, `_meta` survival on native descriptors, verbatim `CUSTOM` round-trip, and the approval state machine). + +## 1. Goals and non-goals + +**Goals (MVP, closes #246):** + +- A versioned, immutable-per-version catalog of typed runtime artifacts. +- Publish, resolve, list, and show over a REST API — no CDK deploy to add an asset. +- Semver-pinned references (`registry://kind/namespace/name@constraint`) resolved at the create-task boundary. +- One end-to-end asset kind proven (`mcp_server`); two more wired but staged (`cedar_policy_module`, `skill`). +- Descriptor validation at publish; resolved `{kind, id, version}` triples stamped on the task record for audit. +- Fail-closed resolution — a task never silently downgrades or substitutes an asset. + +**Non-goals (deferred to child issues / later phases):** + +- Transitive dependencies between registry assets (explicitly disallowed in MVP). +- Plugin, subagent, prompt_fragment, and capability (= workflow) loaders — declared in the grammar, not loaded. +- Federation to / mirroring from upstream registries (official MCP registry, CNCF catalogs). +- Cedar-governed publish ACLs and per-namespace granularity — MVP uses two Cognito groups (§9). + +## 2. Asset kinds for MVP + +| Kind | MVP status | Runtime payload | Applied by | +|------|-----------|----------------|-----------| +| `mcp_server` | **implemented E2E** | `.mcp.json` connection config (transport/url/headers/tool_prefix) | agent → merged into `.mcp.json` | +| `cedar_policy_module` | **implemented E2E** | `cedar_text` (Cedar policy source) | orchestrator → merged into the `cedar_policies` payload (byte-identical to inline blueprint policies) → agent `PolicyEngine` | +| `skill` | **implemented E2E** | `prompt_fragment` (+ `tool_hints`) | agent → appended to the system prompt | +| `plugin`, `subagent`, `prompt_fragment`, `capability` | **reserved** | — | — (grammar accepts them; publish rejects until a loader ships) | + +**Cedar parity.** Registry Cedar text reaches the agent through the **same** `cedar_policies` payload field as inline blueprint policies, so it is byte-identical from the `PolicyEngine`'s view. **Skills** are prompt text only: a skill cannot invoke tools; its `tool_hints` are advisory prose referencing tools an MCP server separately provides (no transitive dependency — the operator attaches both). + +## 3. Substrate mapping (the core design) + +AgentCore Registry answers *"what servers/skills exist, find me one"* (discovery metadata + semantic search). ABCA needs *"give me the exact runtime config to load this pinned asset."* These are two different objects, and the spike proved AgentCore validates the discovery object against the official schemas (an MCP record's `server` body must be a valid MCP `server.json`, not our `.mcp.json`). So **every record carries BOTH a discovery descriptor AND ABCA's runtime payload.** + +### 3.1 Descriptor types + +| Kind | Default AgentCore type | Validated against | Where runtime config lives | +|------|------------------------|-------------------|----------------------------| +| `mcp_server` | `MCP` | official MCP `server.json` | `_meta["dev.abca.runtime"]` inside `server.inlineContent` | +| `skill` | `AGENT_SKILLS` | AgentSkills spec (SKILL.md) | `_meta["dev.abca.runtime"]` inside `skillMd.inlineContent` | +| `cedar_policy_module` | `CUSTOM` | none (arbitrary JSON) | the CUSTOM body's `runtime` field | + +**Purist by default + `--custom` escape hatch.** Native types (`MCP`, `AGENT_SKILLS`) are used by default for their discovery/validation/search value; the runtime payload rides in a `_meta` block on the validated body (spike-verified to survive validation intact). When `--custom` is passed — or when content can't satisfy the official schema — the record is stored as `CUSTOM`, which round-trips its `inlineContent` verbatim (spike-verified). In **both** modes the resolver and agent loaders read the runtime payload, never the validated discovery body. The `--custom` flag toggles validation/discoverability; it does not change the fact that runtime config is stored separately from discovery metadata. + +### 3.2 Namespace (Option A) + +AgentCore has no namespace concept, so ABCA folds `kind/namespace/name` into the record `name` (`mcp_server/acme/pdf-tools`) and the adapter splits/joins on read. This keeps the `registry://` grammar, CLI, resolver, and audit shape unchanged, and uses a single ABCA registry. (Alternatives considered: registry-per-namespace = more infra + single-registry search limits; drop namespace = breaks the grammar + loses ownership scoping.) + +## 4. Ports & adapters + +- **`RegistryClient` port** — substrate-neutral verbs (`publish`, `getRecord`, `listRecords`, `resolve`), one per language: TypeScript (`cdk/src/handlers/shared/registry/client.ts`) for handlers/orchestrator, Python (`agent/src/registry/client.py`, read-only) for the agent. **Nothing upstream imports the AWS SDK directly.** +- **`AgentCoreRegistryClient`** — the one implementation per language (`agentcore-client.ts` / `agentcore_client.py`). Owns: the native-vs-`CUSTOM` descriptor decision, the `_meta` runtime convention, Option-A name encode/decode, the async-record polling, and the multi-call publish (§6). +- **Stays ABCA-side (substrate-agnostic):** the `registry://` grammar (`ref.ts` / `ref.py`, mirrored by the `contracts/registry-resolution/` parity corpus), **semver resolution** (`resolver.ts` / `resolver.py` — AgentCore stores a plain version string, so ranking is always in code), the orchestrator resolve-step, and the agent loaders. +- **Ceded to AgentCore (not built):** MCP/A2A schema validation, hybrid search, EventBridge notifications, CloudTrail audit, and the governance *state machine* (§6). ABCA still *drives* that state machine — see the multi-call publish. + +## 5. Provisioning + +The registry itself is provisioned by a CDK **Provider-framework custom resource** (`cdk/src/constructs/registry.ts`), because `CreateRegistry` is asynchronous (`CREATING → READY`, ~70s observed) and AgentCore has no CDK L1/L2 construct during preview. `onEvent` fires `CreateRegistry`; `isComplete` polls `GetRegistry` until `READY`; teardown drains records then deletes the registry (records block registry deletion). The stack exposes `AgentRegistryId` / `AgentRegistryArn` outputs. **GA-throwaway:** swap this construct for the native AgentCore construct when it ships; the `RegistryClient` seam keeps that swap confined. + +## 6. Governance: the approval state machine + +Records move through `CREATING → DRAFT → PENDING_APPROVAL → APPROVED` (plus `REJECTED`, `DEPRECATED`). **Only `APPROVED` resolves.** The spike established three facts that shape the design: + +1. **`CreateRegistryRecord` is async** and lands in `DRAFT` — even when the registry has `autoApproval: true`. The `autoApproval` flag does **not** auto-publish our records. +2. **`DRAFT → APPROVED` is not a legal direct transition.** `PENDING_APPROVAL` is a mandatory waypoint (`UpdateRegistryRecordStatus(DRAFT→APPROVED)` is rejected). +3. Both transition calls return synchronously; only `create` needs polling. + +So the port's **`publish` is a multi-call operation**, not one SDK call: + +``` +CreateRegistryRecord → poll until not CREATING + → (if autoApprove) SubmitRegistryRecordForApproval → UpdateRegistryRecordStatus(APPROVED) +``` + +"Dev auto-approve" therefore means *ABCA orchestrating these calls under an approver identity*, not the AgentCore `autoApproval` flag. This maps cleanly onto the two Cognito groups (§9): a `RegistryPublisher` publishes (record lands `PENDING_APPROVAL` after submit); a `RegistryApprover` drives the final `UpdateRegistryRecordStatus`. + +## 7. API contract + +All routes are under the existing API Gateway stage (`/v1`), Cognito-authenticated. Wire fields are snake_case. + +### 7.1 `POST /registry/records` — publish + +```jsonc +{ "kind": "mcp_server", "namespace": "acme", "name": "pdf-tools", + "asset_version": "1.4.1", // exact semver, immutable + "discovery": { /* server.json / SKILL.md / arbitrary JSON */ }, + "runtime": { /* connection config / cedar_text / prompt_fragment */ }, + "custom": false, // optional: force verbatim CUSTOM storage + "auto_approve": false } // optional: drive to APPROVED (dev) +``` + +- Auth: `RegistryPublisher`. `auto_approve` additionally requires `RegistryApprover`. +- Validates kind ∈ MVP kinds (reserved kinds rejected), namespace/name shape, exact semver. +- **Immutability:** an existing `(kind, namespace, name, version)` → `409 REGISTRY_VERSION_EXISTS`. +- Response `201`: `{ kind, namespace, name, version, status, storage_mode }`. + +### 7.2 `GET /registry/resolve?ref=registry://…` — resolve + +- Parses the ref, gathers candidate versions, ranks by semver, applies the constraint + status rules (§8). +- Response `200`: `{ kind, namespace, name, version, runtime, warnings[] }`. +- Failure: `422 REGISTRY_RESOLUTION_FAILED` with `reason ∈ { NO_MATCHING_VERSION, REMOVED, INVALID_CONSTRAINT, INVALID_REGISTRY_REF }`. + +### 7.3 `GET /registry/records?kind=&namespace=` — list + +- Response `200`: `{ assets: [{ kind, namespace, name, latest_version, status }] }` (one row per asset, at its highest version). + +### 7.4 `GET /registry/records/{kind}/{namespace}/{name}` — show + +- Response `200`: `{ kind, namespace, name, versions: [{ version, status, created_at, publisher }] }` (highest-first). + +## 8. Resolution semantics + +**Allowed constraint syntaxes** (validated at publish *and* blueprint validation): + +| Syntax | Example | Matches | +|--------|---------|---------| +| exact | `1.4.1` | only `1.4.1` | +| caret | `^1.4.1` | `>=1.4.1 <2.0.0` (`^0.x` keeps the minor) | +| tilde | `~1.4.1` | `>=1.4.1 <1.5.0` | +| *(none)* | `registry://.../pdf-tools` | **rejected** — pins are mandatory | + +**Rejected** with `INVALID_CONSTRAINT` / `INVALID_REGISTRY_REF`: `*`, `latest`, `>=`, `<=`, `<`, `>`, x-ranges, partial versions, and bare prerelease modifiers. + +**Resolution rule:** highest semver-comparable version matching the constraint wins; prereleases rank below their base version (`1.4.1-rc.1` < `1.4.1`) and are excluded from range matches. + +**Status handling:** + +| Status | Resolves? | Behavior | +|--------|-----------|----------| +| `APPROVED` | yes | silent | +| `DEPRECATED` | yes | resolves + `warnings: ["DEPRECATED"]` | +| `DRAFT`, `PENDING_APPROVAL`, `REJECTED`, `CREATING` | no | not a candidate; if the only match → `NO_MATCHING_VERSION` | + +**Fail-closed:** any unresolved ref fails task admission with `REGISTRY_RESOLUTION_FAILED`. A running task never re-resolves or substitutes. + +## 9. Access control (MVP) + +Two Cognito groups (created by the API construct as `CfnUserPoolGroup`): + +- **`RegistryPublisher`** — may `POST /registry/records`; the record is submitted for approval (`PENDING_APPROVAL`). +- **`RegistryApprover`** — additionally drives `UpdateRegistryRecordStatus` to `APPROVED`/`REJECTED`/`DEPRECATED`, and may `auto_approve` on publish (dev). + +Resolve / list / show are available to any authenticated caller. No per-namespace ACL in MVP; Cedar-governed publish ACLs are a later phase. + +## 10. Grammar + +``` +registry:////@ + kind = [a-z][a-z0-9_]* # snake_case: mcp_server, cedar_policy_module + namespace = [a-z][a-z0-9-]* + name = [a-z0-9][a-z0-9._-]* + constraint = [\^~]?MAJOR.MINOR.PATCH[-prerelease] # exact / caret / tilde only +``` + +The strict grammar is implemented by `parseRef` (TS) and `parse_ref` (Python), kept in lockstep by the `contracts/registry-resolution/` parity corpus (dual-runner, mirroring `contracts/cedar-parity/`). + +> **Note — two grammars in the tree.** The workflow validator's `_REGISTRY_REF` (`agent/src/workflow/validator.py`) is a deliberately *looser* acceptance check that also admits the legacy 2-segment illustrative form used by `contracts/workflow-validation/`. The strict grammar above is authoritative for #246 and is what resolution enforces. + +## 11. Orchestrator + agent integration (staged) + +**PR 1 (this work)** ships the resolver library, port, adapter, provisioning, API, and CLI — purely additive; nothing in the orchestrator/agent calls it yet. + +**PR 2** wired the resolve step in the orchestrator (not create-task — see note below): it collects `registry://` refs from the Blueprint, resolves them via the `RegistryClient` (fail-closed — an unresolved ref fails the task), stamps `resolved_assets: [{kind, id, version}]` on the `TaskRecord`, threads the bundle into the agent payload, and loads `mcp_server` assets (merge into `.mcp.json`). + +**PR 3** added the `cedar_policy_module` and `skill` loaders: resolved Cedar text is concatenated into the **same** `cedar_policies` payload field as inline blueprint policies (byte-identical from the `PolicyEngine`'s view — cedar-parity holds by construction), and resolved skill `prompt_fragment`s are appended to the system prompt (`prompt_builder.py`, after channel guidance). + +> **Resolve happens in the orchestrator, not create-task.** `createTaskCore` is shared by 5+ entry-point Lambdas (API, Slack, Jira, Linear, webhook); resolving there would force the AgentCore SDK + IAM into all of them. The orchestrator is a single Lambda that already loads `blueprintConfig` and assembles the payload — exactly how `cedar_policies` already flows. Trade-off: an unresolvable ref surfaces as a FAILED task rather than a 422 at submit. This is still fail-closed (the task never runs with a missing/substituted asset), and Blueprint refs are already validated at synth by the construct. + +## 12. Test plan + +- **Resolver unit tests** (`cdk/test/handlers/shared/registry-resolver.test.ts`): semver match for exact/`^`/`~`; highest-version selection; prerelease ranking; no-match. +- **Grammar parity corpus** (`contracts/registry-resolution/`): annotated `(ref) → verdict` fixtures run against **both** the Python `parse_ref` and the TS `parseRef`. +- **Adapter tests** (`agentcore-client.test.ts`): 3-call publish, native `_meta` embedding, `CUSTOM` verbatim round-trip, immutability, resolve status-filter + semver + deprecation warning. +- **Handler tests**: publish auth/validation/`409`, resolve `422` reasons, list grouping, show. +- **Construct tests**: `registry.test.ts` (Provider wiring + IAM). +- **E2E (PR 2)**: publish an MCP server → reference from a Blueprint → run a task → assert the agent payload carries the bundle and the `TaskRecord` has `resolved_assets`. + +## 13. Accepted risk + +Preview API: AgentCore Registry hard-migrates namespaces at GA (~2026-08-06) with breaking API-schema changes. The `RegistryClient` port confines the rework to one adapter file per language; experimental project + no prod data ⇒ acceptable. Swap the provisioning custom resource for native CDK constructs when they ship at GA. + +## 14. Out of scope (explicit) + +Transitive registry-asset dependencies; plugin/subagent/prompt_fragment/capability loaders; upstream federation; per-namespace ACL; EventBridge as a primary bus; migrating first-party workflows into the registry. diff --git a/docs/src/content/docs/architecture/Deployment-roles.md b/docs/src/content/docs/architecture/Deployment-roles.md index a852a9b67..db27785ae 100644 --- a/docs/src/content/docs/architecture/Deployment-roles.md +++ b/docs/src/content/docs/architecture/Deployment-roles.md @@ -119,6 +119,7 @@ CloudFormation stack operations, IAM roles/policies, VPC networking, and Route 5 ], "Resource": [ "arn:aws:cloudformation:*:*:stack/backgroundagent-dev/*", + "arn:aws:cloudformation:*:*:stack/backgroundagent-dev-*", "arn:aws:cloudformation:*:*:stack/CDKToolkit/*" ] }, @@ -174,6 +175,7 @@ CloudFormation stack operations, IAM roles/policies, VPC networking, and Route 5 "bedrock.amazonaws.com", "bedrock-agentcore.amazonaws.com", "events.amazonaws.com", + "states.amazonaws.com", "vpc-flow-logs.amazonaws.com" ] } @@ -395,6 +397,10 @@ DynamoDB tables, Lambda functions, API Gateway, Cognito, WAFv2, EventBridge, SQS "cognito-idp:DeleteUserPoolClient", "cognito-idp:DescribeUserPoolClient", "cognito-idp:UpdateUserPoolClient", + "cognito-idp:CreateGroup", + "cognito-idp:DeleteGroup", + "cognito-idp:GetGroup", + "cognito-idp:UpdateGroup", "cognito-idp:TagResource", "cognito-idp:UntagResource", "cognito-idp:ListTagsForResource", @@ -453,6 +459,20 @@ DynamoDB tables, Lambda functions, API Gateway, Cognito, WAFv2, EventBridge, SQS ], "Resource": "arn:aws:sqs:*:*:backgroundagent-dev-*" }, + { + "Sid": "StepFunctions", + "Effect": "Allow", + "Action": [ + "states:CreateStateMachine", + "states:DeleteStateMachine", + "states:DescribeStateMachine", + "states:UpdateStateMachine", + "states:TagResource", + "states:UntagResource", + "states:ListTagsForResource" + ], + "Resource": "arn:aws:states:*:*:stateMachine:backgroundagent-dev-*" + }, { "Sid": "CloudFront", "Effect": "Allow", diff --git a/docs/src/content/docs/architecture/Registry.md b/docs/src/content/docs/architecture/Registry.md new file mode 100644 index 000000000..4989172e0 --- /dev/null +++ b/docs/src/content/docs/architecture/Registry.md @@ -0,0 +1,197 @@ +--- +title: Registry +--- + +# Agent asset registry + +A **registry asset** is a versioned, immutable-per-version runtime artifact that a task can load — an MCP server, a Cedar policy module, or a skill. Today those artifacts are vendored into the container image (`agent/src/channel_mcp.py`), inlined on the Blueprint construct (Cedar policies), or committed to a repo (`.mcp.json`). None of them are versioned, none carry an audit trail, and adding one means a **core-code change plus a CDK deploy**. The registry replaces that with a catalog: publishers push typed, versioned records via an API; blueprints pin them by `registry://kind/namespace/name@constraint`; the orchestrator resolves the pins at task start; and the agent receives a resolved bundle. + +- **Use this doc for:** the asset-kind catalog, the substrate mapping (AgentCore descriptor types + the `_meta` runtime convention), the publish/resolve/list/show API contract, resolution semantics (semver, immutability, status), governance (the approval state machine), and how a resolved bundle flows from orchestrator to agent. +- **Related docs:** [WORKFLOWS.md](/sample-autonomous-cloud-coding-agents/architecture/workflows) for the `registry://` grammar and asset-kind vocabulary, [REPO_ONBOARDING.md](/sample-autonomous-cloud-coding-agents/architecture/repo-onboarding) for the per-repo **Blueprint** that references assets, [CEDAR_HITL_GATES.md](/sample-autonomous-cloud-coding-agents/architecture/cedar-hitl-gates) for the policy engine that consumes `cedar_policy_module` assets, [SECURITY.md](/sample-autonomous-cloud-coding-agents/architecture/security) for tool tiers, and [IDENTITY_AND_AUTH.md](/sample-autonomous-cloud-coding-agents/architecture/identity-and-auth) for the Cognito groups that gate publish. +- **Tracking issue:** [#246](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/246). + +> **Substrate: AWS Agent Registry (Bedrock AgentCore).** The registry is built on the managed AgentCore Registry (public preview), not a first-party DynamoDB+S3 store. AgentCore natively provides typed descriptor validation, a governance state machine, hybrid search, and audit — so ABCA builds only the substrate-agnostic parts (grammar, semver resolution, orchestrator/agent integration) plus one adapter. A design-time spike validated this substrate (async provisioning, `_meta` survival on native descriptors, verbatim `CUSTOM` round-trip, and the approval state machine). + +## 1. Goals and non-goals + +**Goals (MVP, closes #246):** + +- A versioned, immutable-per-version catalog of typed runtime artifacts. +- Publish, resolve, list, and show over a REST API — no CDK deploy to add an asset. +- Semver-pinned references (`registry://kind/namespace/name@constraint`) resolved at the create-task boundary. +- One end-to-end asset kind proven (`mcp_server`); two more wired but staged (`cedar_policy_module`, `skill`). +- Descriptor validation at publish; resolved `{kind, id, version}` triples stamped on the task record for audit. +- Fail-closed resolution — a task never silently downgrades or substitutes an asset. + +**Non-goals (deferred to child issues / later phases):** + +- Transitive dependencies between registry assets (explicitly disallowed in MVP). +- Plugin, subagent, prompt_fragment, and capability (= workflow) loaders — declared in the grammar, not loaded. +- Federation to / mirroring from upstream registries (official MCP registry, CNCF catalogs). +- Cedar-governed publish ACLs and per-namespace granularity — MVP uses two Cognito groups (§9). + +## 2. Asset kinds for MVP + +| Kind | MVP status | Runtime payload | Applied by | +|------|-----------|----------------|-----------| +| `mcp_server` | **implemented E2E** | `.mcp.json` connection config (transport/url/headers/tool_prefix) | agent → merged into `.mcp.json` | +| `cedar_policy_module` | **implemented E2E** | `cedar_text` (Cedar policy source) | orchestrator → merged into the `cedar_policies` payload (byte-identical to inline blueprint policies) → agent `PolicyEngine` | +| `skill` | **implemented E2E** | `prompt_fragment` (+ `tool_hints`) | agent → appended to the system prompt | +| `plugin`, `subagent`, `prompt_fragment`, `capability` | **reserved** | — | — (grammar accepts them; publish rejects until a loader ships) | + +**Cedar parity.** Registry Cedar text reaches the agent through the **same** `cedar_policies` payload field as inline blueprint policies, so it is byte-identical from the `PolicyEngine`'s view. **Skills** are prompt text only: a skill cannot invoke tools; its `tool_hints` are advisory prose referencing tools an MCP server separately provides (no transitive dependency — the operator attaches both). + +## 3. Substrate mapping (the core design) + +AgentCore Registry answers *"what servers/skills exist, find me one"* (discovery metadata + semantic search). ABCA needs *"give me the exact runtime config to load this pinned asset."* These are two different objects, and the spike proved AgentCore validates the discovery object against the official schemas (an MCP record's `server` body must be a valid MCP `server.json`, not our `.mcp.json`). So **every record carries BOTH a discovery descriptor AND ABCA's runtime payload.** + +### 3.1 Descriptor types + +| Kind | Default AgentCore type | Validated against | Where runtime config lives | +|------|------------------------|-------------------|----------------------------| +| `mcp_server` | `MCP` | official MCP `server.json` | `_meta["dev.abca.runtime"]` inside `server.inlineContent` | +| `skill` | `AGENT_SKILLS` | AgentSkills spec (SKILL.md) | `_meta["dev.abca.runtime"]` inside `skillMd.inlineContent` | +| `cedar_policy_module` | `CUSTOM` | none (arbitrary JSON) | the CUSTOM body's `runtime` field | + +**Purist by default + `--custom` escape hatch.** Native types (`MCP`, `AGENT_SKILLS`) are used by default for their discovery/validation/search value; the runtime payload rides in a `_meta` block on the validated body (spike-verified to survive validation intact). When `--custom` is passed — or when content can't satisfy the official schema — the record is stored as `CUSTOM`, which round-trips its `inlineContent` verbatim (spike-verified). In **both** modes the resolver and agent loaders read the runtime payload, never the validated discovery body. The `--custom` flag toggles validation/discoverability; it does not change the fact that runtime config is stored separately from discovery metadata. + +### 3.2 Namespace (Option A) + +AgentCore has no namespace concept, so ABCA folds `kind/namespace/name` into the record `name` (`mcp_server/acme/pdf-tools`) and the adapter splits/joins on read. This keeps the `registry://` grammar, CLI, resolver, and audit shape unchanged, and uses a single ABCA registry. (Alternatives considered: registry-per-namespace = more infra + single-registry search limits; drop namespace = breaks the grammar + loses ownership scoping.) + +## 4. Ports & adapters + +- **`RegistryClient` port** — substrate-neutral verbs (`publish`, `getRecord`, `listRecords`, `resolve`), one per language: TypeScript (`cdk/src/handlers/shared/registry/client.ts`) for handlers/orchestrator, Python (`agent/src/registry/client.py`, read-only) for the agent. **Nothing upstream imports the AWS SDK directly.** +- **`AgentCoreRegistryClient`** — the one implementation per language (`agentcore-client.ts` / `agentcore_client.py`). Owns: the native-vs-`CUSTOM` descriptor decision, the `_meta` runtime convention, Option-A name encode/decode, the async-record polling, and the multi-call publish (§6). +- **Stays ABCA-side (substrate-agnostic):** the `registry://` grammar (`ref.ts` / `ref.py`, mirrored by the `contracts/registry-resolution/` parity corpus), **semver resolution** (`resolver.ts` / `resolver.py` — AgentCore stores a plain version string, so ranking is always in code), the orchestrator resolve-step, and the agent loaders. +- **Ceded to AgentCore (not built):** MCP/A2A schema validation, hybrid search, EventBridge notifications, CloudTrail audit, and the governance *state machine* (§6). ABCA still *drives* that state machine — see the multi-call publish. + +## 5. Provisioning + +The registry itself is provisioned by a CDK **Provider-framework custom resource** (`cdk/src/constructs/registry.ts`), because `CreateRegistry` is asynchronous (`CREATING → READY`, ~70s observed) and AgentCore has no CDK L1/L2 construct during preview. `onEvent` fires `CreateRegistry`; `isComplete` polls `GetRegistry` until `READY`; teardown drains records then deletes the registry (records block registry deletion). The stack exposes `AgentRegistryId` / `AgentRegistryArn` outputs. **GA-throwaway:** swap this construct for the native AgentCore construct when it ships; the `RegistryClient` seam keeps that swap confined. + +## 6. Governance: the approval state machine + +Records move through `CREATING → DRAFT → PENDING_APPROVAL → APPROVED` (plus `REJECTED`, `DEPRECATED`). **Only `APPROVED` resolves.** The spike established three facts that shape the design: + +1. **`CreateRegistryRecord` is async** and lands in `DRAFT` — even when the registry has `autoApproval: true`. The `autoApproval` flag does **not** auto-publish our records. +2. **`DRAFT → APPROVED` is not a legal direct transition.** `PENDING_APPROVAL` is a mandatory waypoint (`UpdateRegistryRecordStatus(DRAFT→APPROVED)` is rejected). +3. Both transition calls return synchronously; only `create` needs polling. + +So the port's **`publish` is a multi-call operation**, not one SDK call: + +``` +CreateRegistryRecord → poll until not CREATING + → (if autoApprove) SubmitRegistryRecordForApproval → UpdateRegistryRecordStatus(APPROVED) +``` + +"Dev auto-approve" therefore means *ABCA orchestrating these calls under an approver identity*, not the AgentCore `autoApproval` flag. This maps cleanly onto the two Cognito groups (§9): a `RegistryPublisher` publishes (record lands `PENDING_APPROVAL` after submit); a `RegistryApprover` drives the final `UpdateRegistryRecordStatus`. + +## 7. API contract + +All routes are under the existing API Gateway stage (`/v1`), Cognito-authenticated. Wire fields are snake_case. + +### 7.1 `POST /registry/records` — publish + +```jsonc +{ "kind": "mcp_server", "namespace": "acme", "name": "pdf-tools", + "asset_version": "1.4.1", // exact semver, immutable + "discovery": { /* server.json / SKILL.md / arbitrary JSON */ }, + "runtime": { /* connection config / cedar_text / prompt_fragment */ }, + "custom": false, // optional: force verbatim CUSTOM storage + "auto_approve": false } // optional: drive to APPROVED (dev) +``` + +- Auth: `RegistryPublisher`. `auto_approve` additionally requires `RegistryApprover`. +- Validates kind ∈ MVP kinds (reserved kinds rejected), namespace/name shape, exact semver. +- **Immutability:** an existing `(kind, namespace, name, version)` → `409 REGISTRY_VERSION_EXISTS`. +- Response `201`: `{ kind, namespace, name, version, status, storage_mode }`. + +### 7.2 `GET /registry/resolve?ref=registry://…` — resolve + +- Parses the ref, gathers candidate versions, ranks by semver, applies the constraint + status rules (§8). +- Response `200`: `{ kind, namespace, name, version, runtime, warnings[] }`. +- Failure: `422 REGISTRY_RESOLUTION_FAILED` with `reason ∈ { NO_MATCHING_VERSION, REMOVED, INVALID_CONSTRAINT, INVALID_REGISTRY_REF }`. + +### 7.3 `GET /registry/records?kind=&namespace=` — list + +- Response `200`: `{ assets: [{ kind, namespace, name, latest_version, status }] }` (one row per asset, at its highest version). + +### 7.4 `GET /registry/records/{kind}/{namespace}/{name}` — show + +- Response `200`: `{ kind, namespace, name, versions: [{ version, status, created_at, publisher }] }` (highest-first). + +## 8. Resolution semantics + +**Allowed constraint syntaxes** (validated at publish *and* blueprint validation): + +| Syntax | Example | Matches | +|--------|---------|---------| +| exact | `1.4.1` | only `1.4.1` | +| caret | `^1.4.1` | `>=1.4.1 <2.0.0` (`^0.x` keeps the minor) | +| tilde | `~1.4.1` | `>=1.4.1 <1.5.0` | +| *(none)* | `registry://.../pdf-tools` | **rejected** — pins are mandatory | + +**Rejected** with `INVALID_CONSTRAINT` / `INVALID_REGISTRY_REF`: `*`, `latest`, `>=`, `<=`, `<`, `>`, x-ranges, partial versions, and bare prerelease modifiers. + +**Resolution rule:** highest semver-comparable version matching the constraint wins; prereleases rank below their base version (`1.4.1-rc.1` < `1.4.1`) and are excluded from range matches. + +**Status handling:** + +| Status | Resolves? | Behavior | +|--------|-----------|----------| +| `APPROVED` | yes | silent | +| `DEPRECATED` | yes | resolves + `warnings: ["DEPRECATED"]` | +| `DRAFT`, `PENDING_APPROVAL`, `REJECTED`, `CREATING` | no | not a candidate; if the only match → `NO_MATCHING_VERSION` | + +**Fail-closed:** any unresolved ref fails task admission with `REGISTRY_RESOLUTION_FAILED`. A running task never re-resolves or substitutes. + +## 9. Access control (MVP) + +Two Cognito groups (created by the API construct as `CfnUserPoolGroup`): + +- **`RegistryPublisher`** — may `POST /registry/records`; the record is submitted for approval (`PENDING_APPROVAL`). +- **`RegistryApprover`** — additionally drives `UpdateRegistryRecordStatus` to `APPROVED`/`REJECTED`/`DEPRECATED`, and may `auto_approve` on publish (dev). + +Resolve / list / show are available to any authenticated caller. No per-namespace ACL in MVP; Cedar-governed publish ACLs are a later phase. + +## 10. Grammar + +``` +registry:////@ + kind = [a-z][a-z0-9_]* # snake_case: mcp_server, cedar_policy_module + namespace = [a-z][a-z0-9-]* + name = [a-z0-9][a-z0-9._-]* + constraint = [\^~]?MAJOR.MINOR.PATCH[-prerelease] # exact / caret / tilde only +``` + +The strict grammar is implemented by `parseRef` (TS) and `parse_ref` (Python), kept in lockstep by the `contracts/registry-resolution/` parity corpus (dual-runner, mirroring `contracts/cedar-parity/`). + +> **Note — two grammars in the tree.** The workflow validator's `_REGISTRY_REF` (`agent/src/workflow/validator.py`) is a deliberately *looser* acceptance check that also admits the legacy 2-segment illustrative form used by `contracts/workflow-validation/`. The strict grammar above is authoritative for #246 and is what resolution enforces. + +## 11. Orchestrator + agent integration (staged) + +**PR 1 (this work)** ships the resolver library, port, adapter, provisioning, API, and CLI — purely additive; nothing in the orchestrator/agent calls it yet. + +**PR 2** wired the resolve step in the orchestrator (not create-task — see note below): it collects `registry://` refs from the Blueprint, resolves them via the `RegistryClient` (fail-closed — an unresolved ref fails the task), stamps `resolved_assets: [{kind, id, version}]` on the `TaskRecord`, threads the bundle into the agent payload, and loads `mcp_server` assets (merge into `.mcp.json`). + +**PR 3** added the `cedar_policy_module` and `skill` loaders: resolved Cedar text is concatenated into the **same** `cedar_policies` payload field as inline blueprint policies (byte-identical from the `PolicyEngine`'s view — cedar-parity holds by construction), and resolved skill `prompt_fragment`s are appended to the system prompt (`prompt_builder.py`, after channel guidance). + +> **Resolve happens in the orchestrator, not create-task.** `createTaskCore` is shared by 5+ entry-point Lambdas (API, Slack, Jira, Linear, webhook); resolving there would force the AgentCore SDK + IAM into all of them. The orchestrator is a single Lambda that already loads `blueprintConfig` and assembles the payload — exactly how `cedar_policies` already flows. Trade-off: an unresolvable ref surfaces as a FAILED task rather than a 422 at submit. This is still fail-closed (the task never runs with a missing/substituted asset), and Blueprint refs are already validated at synth by the construct. + +## 12. Test plan + +- **Resolver unit tests** (`cdk/test/handlers/shared/registry-resolver.test.ts`): semver match for exact/`^`/`~`; highest-version selection; prerelease ranking; no-match. +- **Grammar parity corpus** (`contracts/registry-resolution/`): annotated `(ref) → verdict` fixtures run against **both** the Python `parse_ref` and the TS `parseRef`. +- **Adapter tests** (`agentcore-client.test.ts`): 3-call publish, native `_meta` embedding, `CUSTOM` verbatim round-trip, immutability, resolve status-filter + semver + deprecation warning. +- **Handler tests**: publish auth/validation/`409`, resolve `422` reasons, list grouping, show. +- **Construct tests**: `registry.test.ts` (Provider wiring + IAM). +- **E2E (PR 2)**: publish an MCP server → reference from a Blueprint → run a task → assert the agent payload carries the bundle and the `TaskRecord` has `resolved_assets`. + +## 13. Accepted risk + +Preview API: AgentCore Registry hard-migrates namespaces at GA (~2026-08-06) with breaking API-schema changes. The `RegistryClient` port confines the rework to one adapter file per language; experimental project + no prod data ⇒ acceptable. Swap the provisioning custom resource for native CDK constructs when they ship at GA. + +## 14. Out of scope (explicit) + +Transitive registry-asset dependencies; plugin/subagent/prompt_fragment/capability loaders; upstream federation; per-namespace ACL; EventBridge as a primary bus; migrating first-party workflows into the registry. diff --git a/yarn.lock b/yarn.lock index f87bb4e67..e5cb71e3f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -371,6 +371,20 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/client-bedrock-agentcore-control@^3.1078.0": + version "3.1092.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-bedrock-agentcore-control/-/client-bedrock-agentcore-control-3.1092.0.tgz#4f24bdb1ac1dce3478d7a69333c9d7e755534cdc" + integrity sha512-bJkWwmbmJs+8wfS7445P27ew/8fwQKvpTpmP31ugGu2Lx+rhFlNjCb1zvPVml3vOj3NDuxCtH9LMdQqxf9nRYQ== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/credential-provider-node" "^3.972.71" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/fetch-http-handler" "^5.6.6" + "@smithy/node-http-handler" "^4.9.6" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/client-bedrock-agentcore@^3.1078.0": version "3.1081.0" resolved "https://registry.yarnpkg.com/@aws-sdk/client-bedrock-agentcore/-/client-bedrock-agentcore-3.1081.0.tgz#7b652ffe2daef75cf85895dfcf427879e6df386c" @@ -565,6 +579,20 @@ bowser "^2.11.0" tslib "^2.6.2" +"@aws-sdk/core@^3.976.0": + version "3.976.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/core/-/core-3.976.0.tgz#c30d080b4b2ea8e22c07d9d5338c0331050f92da" + integrity sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA== + dependencies: + "@aws-sdk/types" "^3.974.2" + "@aws-sdk/xml-builder" "^3.972.36" + "@aws/lambda-invoke-store" "^0.3.0" + "@smithy/core" "^3.29.4" + "@smithy/signature-v4" "^5.6.5" + "@smithy/types" "^4.16.1" + bowser "^2.11.0" + tslib "^2.6.2" + "@aws-sdk/credential-provider-env@^3.972.55": version "3.972.55" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.55.tgz#1129acf8860db362a30a02a531387fd399448268" @@ -576,6 +604,17 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-env@^3.972.60": + version "3.972.60" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.60.tgz#84fc3b6c950834ccbb84b4b508a4e79027495f46" + integrity sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-http@^3.972.57": version "3.972.57" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.57.tgz#4f73bb2f0e03525ab11e001ac18c39cb120dd14c" @@ -589,6 +628,19 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-http@^3.972.62": + version "3.972.62" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.62.tgz#2104724d4f7ba0838aed0be86a19a2bdc92aee47" + integrity sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/fetch-http-handler" "^5.6.6" + "@smithy/node-http-handler" "^4.9.6" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-ini@^3.972.62": version "3.972.62" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.62.tgz#a2ca7fc7a1899c0a4a38e501baa666cf1449f2e9" @@ -608,6 +660,25 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-ini@^3.973.5": + version "3.973.5" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.5.tgz#83bab3633491e5b54d08e49e242f831a07aa3080" + integrity sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/credential-provider-env" "^3.972.60" + "@aws-sdk/credential-provider-http" "^3.972.62" + "@aws-sdk/credential-provider-login" "^3.972.67" + "@aws-sdk/credential-provider-process" "^3.972.60" + "@aws-sdk/credential-provider-sso" "^3.973.4" + "@aws-sdk/credential-provider-web-identity" "^3.972.66" + "@aws-sdk/nested-clients" "^3.997.34" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/credential-provider-imds" "^4.4.9" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-login@^3.972.61": version "3.972.61" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.61.tgz#f5ba696bae9af3505ae5f3e2a70d7e216d0d37f6" @@ -620,6 +691,18 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-login@^3.972.67": + version "3.972.67" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.67.tgz#7d0cf698c69f5130a4c6c5adb4450f52d81e15a9" + integrity sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/nested-clients" "^3.997.34" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-node@^3.972.61", "@aws-sdk/credential-provider-node@^3.972.64": version "3.972.64" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.64.tgz#282456c24bad616faefe2a6c68701913f8289c10" @@ -637,6 +720,23 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-node@^3.972.71": + version "3.972.71" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.71.tgz#d87fe6da001575845e6332ad118f8f721dfebf52" + integrity sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg== + dependencies: + "@aws-sdk/credential-provider-env" "^3.972.60" + "@aws-sdk/credential-provider-http" "^3.972.62" + "@aws-sdk/credential-provider-ini" "^3.973.5" + "@aws-sdk/credential-provider-process" "^3.972.60" + "@aws-sdk/credential-provider-sso" "^3.973.4" + "@aws-sdk/credential-provider-web-identity" "^3.972.66" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/credential-provider-imds" "^4.4.9" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-process@^3.972.55": version "3.972.55" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.55.tgz#bebd30d0065ca34f64e14a870f54a09f23cf11e2" @@ -648,6 +748,17 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-process@^3.972.60": + version "3.972.60" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.60.tgz#528c752fb31016568fb81b7c05b345d41c611d31" + integrity sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-sso@^3.972.61": version "3.972.61" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.61.tgz#8c15846c65d2f03bfca3347626e1fcd1a0f40726" @@ -661,6 +772,19 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-sso@^3.973.4": + version "3.973.4" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.4.tgz#e6e3cdb687e4acfd7ab228c73102c05e08fb988d" + integrity sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/nested-clients" "^3.997.34" + "@aws-sdk/token-providers" "3.1092.0" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/credential-provider-web-identity@^3.972.61": version "3.972.61" resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.61.tgz#1938cc425e6156acd673484f10b437c5c4c85158" @@ -673,6 +797,18 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/credential-provider-web-identity@^3.972.66": + version "3.972.66" + resolved "https://registry.yarnpkg.com/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.66.tgz#31d0c8b061edc5a87aa6f624dbaac2eb479a6639" + integrity sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/nested-clients" "^3.997.34" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/dynamodb-codec@^3.973.26", "@aws-sdk/dynamodb-codec@^3.973.29": version "3.973.29" resolved "https://registry.yarnpkg.com/@aws-sdk/dynamodb-codec/-/dynamodb-codec-3.973.29.tgz#ea72996b2e1f2c687254c69c357fa4278e42f1da" @@ -783,6 +919,20 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/nested-clients@^3.997.34": + version "3.997.34" + resolved "https://registry.yarnpkg.com/@aws-sdk/nested-clients/-/nested-clients-3.997.34.tgz#bd371ab54f97b6d24d464743e4deb82403d6b890" + integrity sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/signature-v4-multi-region" "^3.996.41" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/fetch-http-handler" "^5.6.6" + "@smithy/node-http-handler" "^4.9.6" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/s3-presigned-post@^3.1078.0": version "3.1081.0" resolved "https://registry.yarnpkg.com/@aws-sdk/s3-presigned-post/-/s3-presigned-post-3.1081.0.tgz#ed27961af8e772e23745e7ff34f34083bc69c291" @@ -818,6 +968,16 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/signature-v4-multi-region@^3.996.41": + version "3.996.41" + resolved "https://registry.yarnpkg.com/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.41.tgz#1ac6743366e2382b73ed14af4b08d21de0bf1ed9" + integrity sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng== + dependencies: + "@aws-sdk/types" "^3.974.2" + "@smithy/signature-v4" "^5.6.5" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/token-providers@3.1078.0": version "3.1078.0" resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.1078.0.tgz#554be2f2c42f21191f31aead718bcedf2047926d" @@ -842,6 +1002,18 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/token-providers@3.1092.0": + version "3.1092.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/token-providers/-/token-providers-3.1092.0.tgz#3178452622c2cac79ed4a0c77733a0edaf9e6b76" + integrity sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ== + dependencies: + "@aws-sdk/core" "^3.976.0" + "@aws-sdk/nested-clients" "^3.997.34" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.29.4" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/types@^3.222.0", "@aws-sdk/types@^3.973.15": version "3.973.15" resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.973.15.tgz#98a4860bed33c32c7088924d0ab52f9eabbdf7c3" @@ -850,6 +1022,14 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/types@^3.974.2": + version "3.974.2" + resolved "https://registry.yarnpkg.com/@aws-sdk/types/-/types-3.974.2.tgz#05e7ccac417735e0786d430d2d5cc139047a08da" + integrity sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA== + dependencies: + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws-sdk/util-dynamodb@^3.996.5": version "3.996.5" resolved "https://registry.yarnpkg.com/@aws-sdk/util-dynamodb/-/util-dynamodb-3.996.5.tgz#2ad647e1532c20c76570a878e49f058a8d21e012" @@ -865,6 +1045,14 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@aws-sdk/xml-builder@^3.972.36": + version "3.972.36" + resolved "https://registry.yarnpkg.com/@aws-sdk/xml-builder/-/xml-builder-3.972.36.tgz#966c17ded23b970b5e41cbab5d1abf85a304b99e" + integrity sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA== + dependencies: + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@aws/durable-execution-sdk-js@^2.0.0": version "2.1.0" resolved "https://registry.yarnpkg.com/@aws/durable-execution-sdk-js/-/durable-execution-sdk-js-2.1.0.tgz#a020f8f3eae0fd3b577ad55397e454241a9e3029" @@ -2744,6 +2932,14 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@smithy/core@^3.29.4", "@smithy/core@^3.29.7": + version "3.29.7" + resolved "https://registry.yarnpkg.com/@smithy/core/-/core-3.29.7.tgz#2d43ad3df0cc26ff5b3bf2653fa24c55cf1dc323" + integrity sha512-BiEE2bnnGoPKdlGe3L+gOYORDHFGPuYVRLP7iUow/Sflm0B4hC4XY3FC1MRuc7ltzpW2xNnXopKi34TTkULlKQ== + dependencies: + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@smithy/credential-provider-imds@^4.4.5": version "4.4.6" resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.6.tgz#048ad961282016fc2d46d457f43beac8ca8f0e24" @@ -2753,6 +2949,15 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@smithy/credential-provider-imds@^4.4.9": + version "4.4.12" + resolved "https://registry.yarnpkg.com/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.12.tgz#7dcc7ca3de3e06bc16288e94072a9e4cc02a7d9c" + integrity sha512-ZZPDbl/aRp77aycuoMlo3BTayT4CE2a3uoqETYZU5ySnVbhpl5IJiY7dCZedn+ZusyDLqVv44IvKBiXd2/nK0Q== + dependencies: + "@smithy/core" "^3.29.7" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@smithy/fetch-http-handler@^5.6.2": version "5.6.3" resolved "https://registry.yarnpkg.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.3.tgz#7b4aec28f89bd1e3aa00dfae0d0bd30079c58341" @@ -2762,6 +2967,15 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@smithy/fetch-http-handler@^5.6.6": + version "5.6.9" + resolved "https://registry.yarnpkg.com/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.9.tgz#a229b47d8cb0407a19cfd827e213dac3e352f6df" + integrity sha512-EJktha5m5MXCwzdXrlWyqb9UCNHNFKlg+PmTpRsdX3dncJPTiqYleM9OKj2mLgdVJHR01d2tU4alG+z2NdH5rQ== + dependencies: + "@smithy/core" "^3.29.7" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@smithy/is-array-buffer@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz#f84f0d9f9a36601a9ca9381688bd1b726fd39111" @@ -2778,6 +2992,15 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@smithy/node-http-handler@^4.9.6": + version "4.9.9" + resolved "https://registry.yarnpkg.com/@smithy/node-http-handler/-/node-http-handler-4.9.9.tgz#51b342b83968ac73a58da7f2c1287fe8c1dd9ec2" + integrity sha512-xVBZ3hptB99iNO9XyWqEhC7KD9bP9UPXhuy3h5Y2ItCfBv160D9IIC/Fmmp3EbnWwit4C+KVqlSE+E29Nk/pPg== + dependencies: + "@smithy/core" "^3.29.7" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@smithy/protocol-http@^5.5.5": version "5.5.6" resolved "https://registry.yarnpkg.com/@smithy/protocol-http/-/protocol-http-5.5.6.tgz#62db7e22b4446168ad091b8fe90d71b05c0fd8bd" @@ -2795,6 +3018,15 @@ "@smithy/types" "^4.15.1" tslib "^2.6.2" +"@smithy/signature-v4@^5.6.5": + version "5.6.8" + resolved "https://registry.yarnpkg.com/@smithy/signature-v4/-/signature-v4-5.6.8.tgz#18f2eedb02cb8088b0d58fb20d2ea9822c9d6c5b" + integrity sha512-iGBm6hIwD2MGvVRSgrjVWa4FXtXDq3akxu0DCpnkmBo0xtEHZ/siMRt7ycfZAefYr2UdywUgmGtoRLaq5u56pg== + dependencies: + "@smithy/core" "^3.29.7" + "@smithy/types" "^4.16.1" + tslib "^2.6.2" + "@smithy/types@^4.15.1": version "4.15.1" resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.15.1.tgz#cdad0fdf4c5d1f5788dc21a3f1fb6af523d79da0" @@ -2802,6 +3034,13 @@ dependencies: tslib "^2.6.2" +"@smithy/types@^4.16.1": + version "4.16.1" + resolved "https://registry.yarnpkg.com/@smithy/types/-/types-4.16.1.tgz#19e199c234829a51c085caf63f0bb17bb80187e4" + integrity sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg== + dependencies: + tslib "^2.6.2" + "@smithy/util-buffer-from@^2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz#6fc88585165ec73f8681d426d96de5d402021e4b" From d3e2b056325d61c4386ad6852a27f5f8901a7d2a Mon Sep 17 00:00:00 2001 From: bgagent Date: Mon, 27 Jul 2026 15:59:46 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(registry):=20close=20TS=E2=86=94Py=20gr?= =?UTF-8?q?ammar=20parity=20gap=20+=20bump=20bootstrap=20version=20(#246)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the catalog PR: - Python ref/semver regexes anchored with \Z instead of $. Python's $ also matches just before a trailing newline, so `registry://…@1.0.0\n` parsed in Python but was rejected by the JS mirror (ref.ts, no m flag) — a byte-for-byte parity break the grammar explicitly promises not to have. Added a `trailing-newline-rejected` case to the shared resolution corpus so CI catches any future regression on either side. - Bumped BOOTSTRAP_VERSION 1.2.0 → 1.3.0 (policy surface changed this PR: Cognito group, Step Functions, CloudFormation nested-stack ARN) and regenerated the bootstrap template so operators know their role is stale. --- agent/src/registry/ref.py | 7 +++++-- agent/src/registry/resolver.py | 4 +++- cdk/bootstrap/BOOTSTRAP_VERSION | 2 +- cdk/bootstrap/bootstrap-template.yaml | 4 ++-- cdk/src/bootstrap/version.ts | 2 +- contracts/registry-resolution/cases.json | 5 +++++ 6 files changed, 17 insertions(+), 7 deletions(-) diff --git a/agent/src/registry/ref.py b/agent/src/registry/ref.py index e4b18f71c..3158670cb 100644 --- a/agent/src/registry/ref.py +++ b/agent/src/registry/ref.py @@ -27,13 +27,16 @@ RESERVED_KINDS = ("plugin", "subagent", "prompt_fragment", "capability") # Structural split — scheme + 3 path segments + the (mandatory) constraint. +# ``\Z`` (absolute end of string), not ``$``: Python's ``$`` also matches just +# before a trailing newline, so ``$`` would accept ``…@1.0.0\n`` that the JS +# mirror (ref.ts, no ``m`` flag) rejects — a byte-for-byte parity break. _REF_SHAPE = re.compile( - r"^registry://([a-z][a-z0-9_]*)/([a-z][a-z0-9-]*)/([a-z0-9][a-z0-9._-]*)@(.+)$" + r"^registry://([a-z][a-z0-9_]*)/([a-z][a-z0-9-]*)/([a-z0-9][a-z0-9._-]*)@(.+)\Z" ) # exact / caret / tilde over MAJOR.MINOR.PATCH with an optional prerelease. # Rejects ``*``, ``latest``, ``>=``, ``<=``, x-ranges, and bare prerelease modifiers. _CONSTRAINT = re.compile( - r"^([\^~]?)(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?$" + r"^([\^~]?)(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?\Z" ) _OP_BY_PREFIX = {"": "exact", "^": "caret", "~": "tilde"} diff --git a/agent/src/registry/resolver.py b/agent/src/registry/resolver.py index 3ba02afab..616840f23 100644 --- a/agent/src/registry/resolver.py +++ b/agent/src/registry/resolver.py @@ -15,7 +15,9 @@ if TYPE_CHECKING: from registry.ref import ParsedConstraint -_SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?$") +# ``\Z`` not ``$`` — see registry/ref.py: ``$`` matches before a trailing +# newline in Python, diverging from the JS mirror (resolver.ts). +_SEMVER = re.compile(r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?\Z") @dataclass(frozen=True) diff --git a/cdk/bootstrap/BOOTSTRAP_VERSION b/cdk/bootstrap/BOOTSTRAP_VERSION index 26aaba0e8..f0bb29e76 100644 --- a/cdk/bootstrap/BOOTSTRAP_VERSION +++ b/cdk/bootstrap/BOOTSTRAP_VERSION @@ -1 +1 @@ -1.2.0 +1.3.0 diff --git a/cdk/bootstrap/bootstrap-template.yaml b/cdk/bootstrap/bootstrap-template.yaml index 45320bbe1..6c4a1d421 100644 --- a/cdk/bootstrap/bootstrap-template.yaml +++ b/cdk/bootstrap/bootstrap-template.yaml @@ -1,6 +1,6 @@ # GENERATED FILE - DO NOT EDIT DIRECTLY # This template is generated by: npx tsx scripts/generate-bootstrap-template.ts -# ABCA Bootstrap Policy Version: 1.2.0 +# ABCA Bootstrap Policy Version: 1.3.0 # ABCA Bootstrap Policy Hash: 29f5882221e3d69e107d985eb63c7ad1c239361c02adebadd57f2d32e401b076 # # Based on the default CDK bootstrap template with the following modifications: @@ -1387,7 +1387,7 @@ Outputs: Value: '32' BootstrapPolicyVersion: Description: The version of the ABCA bootstrap policy bundle - Value: 1.2.0 + Value: 1.3.0 BootstrapPolicyHash: Description: SHA-256 hash of the ABCA bootstrap policy bundle for drift detection Value: 29f5882221e3d69e107d985eb63c7ad1c239361c02adebadd57f2d32e401b076 diff --git a/cdk/src/bootstrap/version.ts b/cdk/src/bootstrap/version.ts index d81d32326..a8e60b44d 100644 --- a/cdk/src/bootstrap/version.ts +++ b/cdk/src/bootstrap/version.ts @@ -22,7 +22,7 @@ import { createHash } from 'node:crypto'; import { allPolicies } from './policies'; /** Semantic version of the bootstrap policy bundle. */ -export const BOOTSTRAP_VERSION = '1.2.0'; +export const BOOTSTRAP_VERSION = '1.3.0'; /** * Computes a SHA-256 hash over all bootstrap policies. diff --git a/contracts/registry-resolution/cases.json b/contracts/registry-resolution/cases.json index 6c240a68a..8ae2abb61 100644 --- a/contracts/registry-resolution/cases.json +++ b/contracts/registry-resolution/cases.json @@ -90,6 +90,11 @@ "name": "caret-zero-minor", "ref": "registry://mcp_server/acme/pdf-tools@^0.2.0", "expected": { "ok": true, "kind": "mcp_server", "namespace": "acme", "name": "pdf-tools", "op": "caret", "major": 0, "minor": 2, "patch": 0, "prerelease": null } + }, + { + "name": "trailing-newline-rejected", + "ref": "registry://mcp_server/acme/pdf-tools@1.0.0\n", + "expected": { "ok": false, "reason": "INVALID_REGISTRY_REF" } } ] }