From 5647bf5cd9cd56a488069b12c10d6a134454ab46 Mon Sep 17 00:00:00 2001 From: bgagent Date: Mon, 27 Jul 2026 13:05:21 -0400 Subject: [PATCH 1/5] =?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/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 | 95 +++++ 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 ++ 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 | 14 + 48 files changed, 4176 insertions(+), 1 deletion(-) 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/policies/application.json b/cdk/bootstrap/policies/application.json index 20010171d..f130da20a 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", @@ -189,6 +193,20 @@ "Resource": "arn:aws:sns:*:*:backgroundagent-dev-*", "Sid": "SNS" }, + { + "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 619c994d2..98d672614 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 9171c06f7..5493ca743 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', @@ -233,6 +238,23 @@ export function applicationPolicy(): iam.PolicyDocument { resources: ['arn:aws:sns:*:*: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 399788db5..af0dfbe43 100644 --- a/cdk/src/bootstrap/resource-action-map.ts +++ b/cdk/src/bootstrap/resource-action-map.ts @@ -68,10 +68,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'], @@ -111,9 +115,13 @@ export const RESOURCE_ACTION_MAP: Record = { 'AWS::SNS::Subscription': ['sns:Subscribe'], 'AWS::SNS::Topic': ['sns:CreateTopic'], '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 e8578c58c..e7bceed29 100644 --- a/cdk/src/constructs/task-api.ts +++ b/cdk/src/constructs/task-api.ts @@ -232,6 +232,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; } /** @@ -1379,6 +1386,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, [ 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 cf380c2ee..2a33b3f8d 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; @@ -354,6 +429,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; @@ -440,6 +517,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; @@ -820,6 +899,7 @@ export function toTaskDetail( 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, @@ -1092,6 +1172,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 722151b6e..ab3da97ad 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -57,6 +57,7 @@ import { OperationalAlerts } from '../constructs/operational-alerts'; import { OrchestrationReconciler } from '../constructs/orchestration-reconciler'; import { OrchestrationTable } from '../constructs/orchestration-table'; 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 { buildAppId } from '../constructs/solution-ua-aspect'; @@ -124,6 +125,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 @@ -378,6 +393,7 @@ export class AgentStack extends Stack { // immediately. Omitted when no image is configured — there can be no // MicroVM-backed task to cancel then. ...(microvmImageConfigured && { lambdaMicrovmImageArn: lazyMicrovmImageArn }), + agentRegistryId: agentRegistry.registryId, }); // --- Tool-federation Gateway (ADR-019 P1, CONTEXT-GATED) --- @@ -715,6 +731,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/policies.test.ts b/cdk/test/bootstrap/policies.test.ts index 6ee502713..7be418534 100644 --- a/cdk/test/bootstrap/policies.test.ts +++ b/cdk/test/bootstrap/policies.test.ts @@ -113,6 +113,7 @@ describe('IaCRole-ABCA-Application', () => { 'EventBridge', 'SQS', 'SNS', + 'StepFunctions', 'CloudFront', 'SecretsManager', 'SecretsManagerAccountLevel', @@ -147,6 +148,7 @@ describe('IaCRole-ABCA-Application', () => { 'secretsmanager', 'sns', '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 5c9535f24..207ce36d2 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'; @@ -91,6 +92,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 90a64c4a7..bf425924c 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'; @@ -91,6 +158,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; @@ -244,6 +313,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 7ec30db1a..9ae4bb1a0 100644 --- a/docs/design/DEPLOYMENT_ROLES.md +++ b/docs/design/DEPLOYMENT_ROLES.md @@ -116,6 +116,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/*" ] }, @@ -171,6 +172,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" ] } @@ -392,6 +394,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", @@ -468,6 +474,20 @@ DynamoDB tables, Lambda functions, API Gateway, Cognito, WAFv2, EventBridge, SQS ], "Resource": "arn:aws:sns:*:*: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 da61f26b5..7c5de6860 100644 --- a/docs/src/content/docs/architecture/Deployment-roles.md +++ b/docs/src/content/docs/architecture/Deployment-roles.md @@ -120,6 +120,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/*" ] }, @@ -175,6 +176,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" ] } @@ -396,6 +398,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", @@ -472,6 +478,20 @@ DynamoDB tables, Lambda functions, API Gateway, Cognito, WAFv2, EventBridge, SQS ], "Resource": "arn:aws:sns:*:*: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 44aed7cd6..5a888aa6c 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.1106.0" + resolved "https://registry.yarnpkg.com/@aws-sdk/client-bedrock-agentcore-control/-/client-bedrock-agentcore-control-3.1106.0.tgz#818583ef1b8da270bcee2cb90d936a737d52ef9c" + integrity sha512-jhs4jyApl3REd2BP/YBw5aXTOZ0NvTdvF7WjBc1KVmKXIin2JDczyechrYqpBth59CkUblxKPtVbwmZ3FQxCkw== + dependencies: + "@aws-sdk/core" "^3.977.6" + "@aws-sdk/credential-provider-node" "^3.972.78" + "@aws-sdk/types" "^3.974.2" + "@smithy/core" "^3.31.1" + "@smithy/fetch-http-handler" "^5.6.13" + "@smithy/node-http-handler" "^4.9.13" + "@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" From 7c5d4b9a02e7329e817de50851f6e0bdccb02240 Mon Sep 17 00:00:00 2001 From: bgagent Date: Mon, 27 Jul 2026 15:59:46 -0400 Subject: [PATCH 2/5] =?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_HASH | 2 +- cdk/bootstrap/BOOTSTRAP_VERSION | 2 +- cdk/bootstrap/bootstrap-template.yaml | 25 ++++++++++++++++--- cdk/src/bootstrap/version.ts | 9 ++++--- .../__snapshots__/version.test.ts.snap | 2 +- contracts/registry-resolution/cases.json | 5 ++++ 8 files changed, 43 insertions(+), 13 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_HASH b/cdk/bootstrap/BOOTSTRAP_HASH index f4e0126c2..5f73b7f62 100644 --- a/cdk/bootstrap/BOOTSTRAP_HASH +++ b/cdk/bootstrap/BOOTSTRAP_HASH @@ -1 +1 @@ -4591304250a01e0d9e45d6890ab09a0b69aa6199b1149399fbb6418a91d1d77d +239c205649e1ebcc688b519cb24f14069703fd9ae4c568507ea589b901179dd1 diff --git a/cdk/bootstrap/BOOTSTRAP_VERSION b/cdk/bootstrap/BOOTSTRAP_VERSION index 88c5fb891..bc80560fa 100644 --- a/cdk/bootstrap/BOOTSTRAP_VERSION +++ b/cdk/bootstrap/BOOTSTRAP_VERSION @@ -1 +1 @@ -1.4.0 +1.5.0 diff --git a/cdk/bootstrap/bootstrap-template.yaml b/cdk/bootstrap/bootstrap-template.yaml index b6fd16205..64a6cdb9d 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.4.0 -# ABCA Bootstrap Policy Hash: 4591304250a01e0d9e45d6890ab09a0b69aa6199b1149399fbb6418a91d1d77d +# ABCA Bootstrap Policy Version: 1.5.0 +# ABCA Bootstrap Policy Hash: 239c205649e1ebcc688b519cb24f14069703fd9ae4c568507ea589b901179dd1 # # Based on the default CDK bootstrap template with the following modifications: # - BootstrapVariant set to "ABCA: Least-Privilege Bootstrap" @@ -831,6 +831,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: @@ -877,6 +878,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-* @@ -1071,6 +1073,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 @@ -1134,6 +1140,17 @@ Resources: Effect: Allow Resource: arn:aws:sns:*:*:backgroundagent-dev-* Sid: SNS + - 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 @@ -1456,10 +1473,10 @@ Outputs: Value: '32' BootstrapPolicyVersion: Description: The version of the ABCA bootstrap policy bundle - Value: 1.4.0 + Value: 1.5.0 BootstrapPolicyHash: Description: SHA-256 hash of the ABCA bootstrap policy bundle for drift detection - Value: 4591304250a01e0d9e45d6890ab09a0b69aa6199b1149399fbb6418a91d1d77d + Value: 239c205649e1ebcc688b519cb24f14069703fd9ae4c568507ea589b901179dd1 BootstrapPolicySet: Description: Comma-separated list of active ABCA bootstrap policy names Value: diff --git a/cdk/src/bootstrap/version.ts b/cdk/src/bootstrap/version.ts index 153828a43..d1668fd4b 100644 --- a/cdk/src/bootstrap/version.ts +++ b/cdk/src/bootstrap/version.ts @@ -28,10 +28,13 @@ import { allPolicies } from './policies'; * 1.2.0 refreshed policies for a full deploy (#350), 1.2.0 → 1.3.0 adds the * `compute-lambda-microvm` policy (#645 / ADR-021), 1.3.0 → 1.4.0 grants SNS * topic + customer-managed-KMS-key create/lifecycle for the OperationalAlerts - * notification channel (#629). Expanding the granted action set is a minor - * bump — that is the precedent the #350 policy refresh set. + * notification channel (#629), 1.4.0 → 1.5.0 adds the agent asset registry + * policies (#246: bedrock-agentcore registry + workload identity, Step + * Functions, Cognito group, and CloudFormation nested-stack actions). Adding + * policies to the bundle is a minor bump — that is the precedent `compute-ecs` + * set. */ -export const BOOTSTRAP_VERSION = '1.4.0'; +export const BOOTSTRAP_VERSION = '1.5.0'; /** * Computes a SHA-256 hash over all bootstrap policies. diff --git a/cdk/test/bootstrap/__snapshots__/version.test.ts.snap b/cdk/test/bootstrap/__snapshots__/version.test.ts.snap index fb7d2de22..faf755513 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`] = `"4591304250a01e0d9e45d6890ab09a0b69aa6199b1149399fbb6418a91d1d77d"`; +exports[`bootstrap version module hash is stable 1`] = `"239c205649e1ebcc688b519cb24f14069703fd9ae4c568507ea589b901179dd1"`; 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" } } ] } From 9a86b99627d47eb270b650dccede91485df8b8a4 Mon Sep 17 00:00:00 2001 From: bgagent Date: Wed, 5 Aug 2026 11:28:07 -0400 Subject: [PATCH 3/5] =?UTF-8?q?fix(registry):=20address=20review=20on=20th?= =?UTF-8?q?e=20catalog=20PR=20=E2=80=94=20security,=20publish=20correctnes?= =?UTF-8?q?s,=20provisioning=20(#246)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security: - Redact secret headers in the resolve response (open to any authenticated caller); orchestrator port path stays unredacted. - Scope registry handler IAM to registry/ + /record/*, not *. Publish / adapter correctness: - Always SubmitForApproval so a normal publish reaches PENDING_APPROVAL; gate only the APPROVED transition on autoApprove. - Enforce the per-kind runtime contract at publish (reject arrays/empty/ wrong-kind) instead of only typeof object. - Treat async CREATE_FAILED and poll-budget exhaustion as publish failure (was silent success). - Base64-encode skill runtime in SKILL.md frontmatter (apostrophe/newline-safe) with a legacy single-quoted-JSON fallback; TS+Py parity. - Persist the authenticated publisher across MCP/skill/CUSTOM and surface it in show (was always null). Provisioning: - Make CreateRegistry replay-safe via a clientToken derived from the CFN RequestId. - Handle custom-resource Update (apply name/description via UpdateRegistry) instead of reporting success while ignoring desired state; add UpdateRegistry to the scoped registry IAM policy. Parity + nits: - Reject semver components beyond MAX_SAFE_INTEGER in TS+Py (+ corpus case). - Drop the no-op try/catch, hoist the double parseConstraint, add TODO(GA) on the O(n) list path, and name RegistryShowResponse (types-sync guarded). --- agent/src/registry/agentcore_client.py | 35 ++- agent/src/registry/ref.py | 14 +- agent/src/registry/resolver.py | 15 +- agent/tests/test_registry_agentcore_client.py | 90 ++++++- ...test_registry_resolution_ranking_corpus.py | 58 +++++ cdk/src/constructs/registry.ts | 1 + cdk/src/constructs/task-api.ts | 10 +- .../handlers/registry-provisioning/index.ts | 51 +++- cdk/src/handlers/registry-publish.ts | 76 +++++- cdk/src/handlers/registry-resolve.ts | 36 ++- cdk/src/handlers/registry-show.ts | 5 +- .../shared/registry/agentcore-client.ts | 210 +++++++++++---- cdk/src/handlers/shared/registry/ref.ts | 15 +- cdk/src/handlers/shared/registry/resolver.ts | 14 +- cdk/src/handlers/shared/registry/types.ts | 31 +++ cdk/src/handlers/shared/response.ts | 1 + cdk/src/handlers/shared/types.ts | 10 + cdk/test/constructs/registry.test.ts | 1 + cdk/test/handlers/registry-handlers.test.ts | 96 +++++++ .../registry-provisioning/index.test.ts | 243 ++++++++++++++++++ .../handlers/shared/agentcore-client.test.ts | 190 +++++++++++++- ...registry-resolution-ranking-parity.test.ts | 70 +++++ cli/src/api-client.ts | 8 +- cli/src/types.ts | 9 + contracts/registry-resolution/README.md | 17 +- contracts/registry-resolution/cases.json | 5 + .../registry-resolution/resolution-cases.json | 77 ++++++ 27 files changed, 1294 insertions(+), 94 deletions(-) create mode 100644 agent/tests/test_registry_resolution_ranking_corpus.py create mode 100644 cdk/test/handlers/registry-provisioning/index.test.ts create mode 100644 cdk/test/handlers/shared/registry-resolution-ranking-parity.test.ts create mode 100644 contracts/registry-resolution/resolution-cases.json diff --git a/agent/src/registry/agentcore_client.py b/agent/src/registry/agentcore_client.py index 976c0a8a6..47523e224 100644 --- a/agent/src/registry/agentcore_client.py +++ b/agent/src/registry/agentcore_client.py @@ -8,6 +8,7 @@ from __future__ import annotations +import base64 import json import re from typing import TYPE_CHECKING, Any @@ -27,7 +28,9 @@ # 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) +# Capture the whole frontmatter value; the runtime is base64-encoded JSON (new +# form) or, for records published before the base64 switch, single-quoted JSON. +_SKILL_RUNTIME_RE = re.compile(rf"^{_SKILL_RUNTIME_FM_KEY}:\s*(.+?)\s*$", re.MULTILINE) _RESOLVABLE_STATUSES = ("APPROVED", "DEPRECATED") @@ -69,7 +72,13 @@ def _extract_runtime(self, raw: dict[str, Any]) -> dict[str, Any]: descriptors.get("agentSkills", {}).get("skillMd", {}).get("inlineContent", "") ) m = _SKILL_RUNTIME_RE.search(skill_md) - return json.loads(m.group(1)) if m else {} + if not m: + return {} + raw_value = m.group(1) + # Legacy form: '' (single-quoted). New form: bare base64. + if raw_value.startswith("'") and raw_value.endswith("'"): + return json.loads(raw_value[1:-1]) + return json.loads(base64.b64decode(raw_value).decode("utf-8")) # MCP: JSON server.json with the runtime in a `_meta` block. inline = descriptors.get("mcp", {}).get("server", {}).get("inlineContent") or "{}" body = json.loads(inline) @@ -133,12 +142,32 @@ def resolve(self, ref: ParsedRef) -> ResolvedAsset: f"satisfies {ref.constraint.raw}", ) winner = by_version[winning] + # Fail closed: a resolvable record whose runtime payload is empty or + # unreadable must NOT resolve to {} — that would let a task load nothing + # while the audit claims the pin was honored (REGISTRY.md §8). A corrupt + # _meta/CUSTOM body or an out-of-band write can produce this. + try: + runtime = self._extract_runtime(winner) + except (ValueError, json.JSONDecodeError) as exc: + raise RegistryResolutionError( + "REMOVED", + ref_str, + f"resolved {ref.kind}/{ref.namespace}/{ref.name}@{winning} " + f"has an unreadable runtime payload: {exc}", + ) from exc + if not isinstance(runtime, dict) or not runtime: + raise RegistryResolutionError( + "REMOVED", + ref_str, + f"resolved {ref.kind}/{ref.namespace}/{ref.name}@{winning} " + f"has no loadable runtime payload", + ) 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), + runtime=runtime, warnings=warnings, ) diff --git a/agent/src/registry/ref.py b/agent/src/registry/ref.py index 3158670cb..dc0c36c99 100644 --- a/agent/src/registry/ref.py +++ b/agent/src/registry/ref.py @@ -40,6 +40,11 @@ ) _OP_BY_PREFIX = {"": "exact", "^": "caret", "~": "tilde"} +# JS ``Number.MAX_SAFE_INTEGER`` (2**53 - 1). Python int is arbitrary-precision, +# but the TS parser rounds components above this, so both sides must reject them +# to agree on version selection (#246 parity — see registry/ref.ts). +_MAX_SAFE_INT = 9007199254740991 + class RefError(ValueError): """Raised when a ``registry://`` ref is malformed. @@ -78,11 +83,14 @@ def parse_constraint(raw: str) -> ParsedConstraint | None: if not m: return None prefix, major, minor, patch, prerelease = m.groups() + major_i, minor_i, patch_i = int(major), int(minor), int(patch) + if max(major_i, minor_i, patch_i) > _MAX_SAFE_INT: + return None return ParsedConstraint( op=_OP_BY_PREFIX[prefix], - major=int(major), - minor=int(minor), - patch=int(patch), + major=major_i, + minor=minor_i, + patch=patch_i, prerelease=prerelease, raw=raw, ) diff --git a/agent/src/registry/resolver.py b/agent/src/registry/resolver.py index 616840f23..3bf254d00 100644 --- a/agent/src/registry/resolver.py +++ b/agent/src/registry/resolver.py @@ -19,6 +19,10 @@ # 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") +# JS ``Number.MAX_SAFE_INTEGER`` (2**53 - 1); mirrors registry/ref.py._MAX_SAFE_INT. +# Kept local rather than imported to avoid a runtime import cycle with ref.py. +_MAX_SAFE_INT = 9007199254740991 + @dataclass(frozen=True) class SemVer: @@ -34,10 +38,15 @@ def parse_version(raw: str) -> SemVer | None: if not m: return None major, minor, patch, pre = m.groups() + major_i, minor_i, patch_i = int(major), int(minor), int(patch) + # Match ref.parse_constraint / resolver.ts: reject components the TS parser + # would round past MAX_SAFE_INTEGER so a candidate version ranks identically. + if max(major_i, minor_i, patch_i) > _MAX_SAFE_INT: + return None return SemVer( - major=int(major), - minor=int(minor), - patch=int(patch), + major=major_i, + minor=minor_i, + patch=patch_i, prerelease=tuple(pre.split(".")) if pre else (), raw=raw, ) diff --git a/agent/tests/test_registry_agentcore_client.py b/agent/tests/test_registry_agentcore_client.py index 6ecd48c26..1af87a702 100644 --- a/agent/tests/test_registry_agentcore_client.py +++ b/agent/tests/test_registry_agentcore_client.py @@ -8,9 +8,14 @@ from __future__ import annotations +import base64 import json +import pytest + from registry.agentcore_client import AgentCoreRegistryClient +from registry.client import RegistryResolutionError +from registry.ref import parse_ref _RUNTIME_META_KEY = "dev.abca.runtime" @@ -19,6 +24,23 @@ def _client() -> AgentCoreRegistryClient: return AgentCoreRegistryClient("r", None) +class _FakeBoto: + """Minimal bedrock-agentcore-control stand-in: one page of records, and + get_registry_record echoing the seeded record by id.""" + + def __init__(self, records: list[dict]) -> None: + self._records = records + + def list_registry_records(self, **_kwargs): + return {"registryRecords": self._records, "nextToken": None} + + def get_registry_record(self, *, registryId, recordId): + for r in self._records: + if r.get("recordId") == recordId: + return r + return {} + + class TestExtractRuntime: def test_custom_reads_body_runtime(self): runtime = {"cedar_text": "forbid(principal, action, resource);"} @@ -39,24 +61,40 @@ def test_mcp_reads_meta_block(self): } 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"]} + @staticmethod + def _skill_md(runtime_line: str) -> dict: skill_md = ( "---\n" "name: acme-readme-helper\n" "description: d\n" "version: 1.0.0\n" - f"x-abca-runtime: '{json.dumps(runtime)}'\n" + f"{runtime_line}\n" "---\n" "# acme/readme-helper\n" "body" ) - raw = { + return { "descriptorType": "AGENT_SKILLS", "descriptors": {"agentSkills": {"skillMd": {"inlineContent": skill_md}}}, } - assert _client()._extract_runtime(raw) == runtime + + def test_agent_skills_parses_base64_frontmatter(self): + # The shape the TS adapter's buildSkillMd emits: base64-encoded JSON. + runtime = {"prompt_fragment": "Add a note.", "tool_hints": ["Edit"]} + b64 = base64.b64encode(json.dumps(runtime).encode()).decode() + assert _client()._extract_runtime(self._skill_md(f"x-abca-runtime: {b64}")) == runtime + + def test_agent_skills_base64_survives_apostrophe(self): + # The exact payload that broke single-quoted YAML frontmatter (#246). + runtime = {"prompt_fragment": "Don't skip tests; it's required.", "tool_hints": ["Don't"]} + b64 = base64.b64encode(json.dumps(runtime).encode()).decode() + assert _client()._extract_runtime(self._skill_md(f"x-abca-runtime: {b64}")) == runtime + + def test_agent_skills_parses_legacy_single_quoted_json(self): + # Records published before the base64 switch must still resolve. + runtime = {"prompt_fragment": "Add a note.", "tool_hints": ["Edit"]} + line = f"x-abca-runtime: '{json.dumps(runtime)}'" + assert _client()._extract_runtime(self._skill_md(line)) == runtime def test_agent_skills_missing_frontmatter_key_returns_empty(self): skill_md = "---\nname: x\ndescription: d\nversion: 1.0.0\n---\nbody" @@ -65,3 +103,43 @@ def test_agent_skills_missing_frontmatter_key_returns_empty(self): "descriptors": {"agentSkills": {"skillMd": {"inlineContent": skill_md}}}, } assert _client()._extract_runtime(raw) == {} + + +class TestResolveFailClosed: + """resolve() must never hand back a record with an empty/unreadable runtime — + that would let a task run with a missing/substituted asset (REGISTRY.md §8).""" + + @staticmethod + def _mcp_record(version: str, status: str, *, with_runtime: bool) -> dict: + server: dict = {"name": "acme/pdf-tools", "version": version} + if with_runtime: + server["_meta"] = {_RUNTIME_META_KEY: {"type": "http", "url": "https://x"}} + return { + "recordId": f"rec-{version}", + "recordArn": f"arn:aws:bedrock-agentcore:us-east-1:1:registry/r/record/rec-{version}", + "name": "mcp_server/acme/pdf-tools", + "descriptorType": "MCP", + "descriptors": {"mcp": {"server": {"inlineContent": json.dumps(server)}}}, + "recordVersion": version, + "status": status, + } + + def _resolve(self, records: list[dict], ref_str: str): + client = AgentCoreRegistryClient("r", _FakeBoto(records)) + return client.resolve(parse_ref(ref_str)) + + def test_resolves_when_runtime_present(self): + asset = self._resolve( + [self._mcp_record("1.4.1", "APPROVED", with_runtime=True)], + "registry://mcp_server/acme/pdf-tools@1.4.1", + ) + assert asset.version == "1.4.1" + assert asset.runtime == {"type": "http", "url": "https://x"} + + def test_fails_closed_when_approved_record_has_empty_runtime(self): + with pytest.raises(RegistryResolutionError) as exc: + self._resolve( + [self._mcp_record("1.4.1", "APPROVED", with_runtime=False)], + "registry://mcp_server/acme/pdf-tools@1.4.1", + ) + assert exc.value.reason == "REMOVED" diff --git a/agent/tests/test_registry_resolution_ranking_corpus.py b/agent/tests/test_registry_resolution_ranking_corpus.py new file mode 100644 index 000000000..469d86fca --- /dev/null +++ b/agent/tests/test_registry_resolution_ranking_corpus.py @@ -0,0 +1,58 @@ +"""Semver RESOLUTION-ranking parity corpus runner (Python side) (#246). + +Loads ``contracts/registry-resolution/resolution-cases.json`` and asserts the +Python ``registry.resolver.select_highest`` picks the golden winner for each +(candidates, constraint). The TypeScript runner +(``cdk/test/handlers/shared/registry-resolution-ranking-parity.test.ts``) runs +the same file against ``selectHighest``; both must agree, so caret/tilde/ +prerelease ranking cannot drift between the API path (TS) and the orchestrator's +direct port path (Python). +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from registry.ref import parse_constraint +from registry.resolver import select_highest + +_CASES_FILE = ( + Path(os.path.dirname(__file__)) + / ".." + / ".." + / "contracts" + / "registry-resolution" + / "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_select_highest_matches_fixture(case: dict) -> None: + constraint = parse_constraint(case["constraint"]) + assert constraint is not None, f"{case['name']}: constraint should parse" + winner = select_highest(case["candidates"], constraint) + assert winner == case["winner"], ( + f"{case['name']}: winner drift — got {winner!r}, expected {case['winner']!r}" + ) + + +def test_corpus_present_and_nonempty() -> None: + assert _CASES_FILE.is_file() + assert len(_CASES) >= 1 diff --git a/cdk/src/constructs/registry.ts b/cdk/src/constructs/registry.ts index 2b470334e..195343cde 100644 --- a/cdk/src/constructs/registry.ts +++ b/cdk/src/constructs/registry.ts @@ -107,6 +107,7 @@ export class AgentRegistry extends Construct { const registryPolicy = new iam.PolicyStatement({ actions: [ 'bedrock-agentcore:GetRegistry', + 'bedrock-agentcore:UpdateRegistry', 'bedrock-agentcore:DeleteRegistry', 'bedrock-agentcore:ListRegistryRecords', 'bedrock-agentcore:DeleteRegistryRecord', diff --git a/cdk/src/constructs/task-api.ts b/cdk/src/constructs/task-api.ts index e7bceed29..c862f5ad5 100644 --- a/cdk/src/constructs/task-api.ts +++ b/cdk/src/constructs/task-api.ts @@ -1427,17 +1427,21 @@ export class TaskApi extends Construct { const registryShowFn = registryFn('RegistryShowFn', 'registry-show.ts'); const registryFns = [registryPublishFn, registryResolveFn, registryListFn, registryShowFn]; - // Control-plane + data-plane actions, scoped to this account's registries. + // Control-plane + data-plane actions, scoped to THIS registry (not every + // registry in the account). The registry id is known at synth time + // (props.agentRegistryId), unlike create-time provisioning where it isn't — + // so there's no reason to widen to `registry/*`. Records get server-assigned + // ids, so the record ARN keeps a `/record/*` wildcard under this registry. const registryArn = Stack.of(this).formatArn({ service: 'bedrock-agentcore', resource: 'registry', - resourceName: '*', + resourceName: props.agentRegistryId, arnFormat: ArnFormat.SLASH_RESOURCE_NAME, }); const recordArn = Stack.of(this).formatArn({ service: 'bedrock-agentcore', resource: 'registry', - resourceName: '*/record/*', + resourceName: `${props.agentRegistryId}/record/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME, }); const readActions = [ diff --git a/cdk/src/handlers/registry-provisioning/index.ts b/cdk/src/handlers/registry-provisioning/index.ts index 2b414fb12..34b27629d 100644 --- a/cdk/src/handlers/registry-provisioning/index.ts +++ b/cdk/src/handlers/registry-provisioning/index.ts @@ -24,10 +24,12 @@ // // 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 { createHash } from 'node:crypto'; import { BedrockAgentCoreControlClient, CreateRegistryCommand, GetRegistryCommand, + UpdateRegistryCommand, DeleteRegistryCommand, ListRegistryRecordsCommand, DeleteRegistryRecordCommand, @@ -41,7 +43,12 @@ import { logger } from '../shared/logger'; interface OnEventRequest { readonly RequestType: 'Create' | 'Update' | 'Delete'; readonly PhysicalResourceId?: string; + /** CloudFormation request id — stable per logical CFN operation, so it makes a + * good idempotency token for the async CreateRegistry (Provider handlers are + * delivered at-least-once). Always present in Provider-framework events. */ + readonly RequestId?: string; readonly ResourceProperties: { readonly RegistryName: string; readonly Description?: string }; + readonly OldResourceProperties?: { readonly RegistryName?: string; readonly Description?: string }; } interface OnEventResponse { readonly PhysicalResourceId?: string; @@ -62,23 +69,57 @@ function registryIdFromArn(arn: string): string { return arn.includes('/') ? arn.split('/').pop()! : arn; } +/** A deterministic, charset-safe idempotency token for CreateRegistry. Derived + * from the stable CFN RequestId (falls back to the registry name if absent) so + * an at-least-once retry of the same logical create is a substrate no-op rather + * than a duplicate registry. */ +function createTokenFrom(requestId: string | undefined, registryName: string): string { + return createHash('sha256').update(`${requestId ?? ''}:${registryName}`).digest('hex').slice(0, 64); +} + 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; + // Idempotency: Provider handlers are delivered at-least-once, so a lost + // response after a successful CreateRegistry would, on retry, create a + // *second* registry and strand the stack. A clientToken derived from the + // stable CFN RequestId makes the retry a no-op on the substrate side. const res = await client.send( - new CreateRegistryCommand({ name: RegistryName, description: Description }), + new CreateRegistryCommand({ + name: RegistryName, + description: Description, + clientToken: createTokenFrom(event.RequestId, RegistryName), + }), ); 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 }; + // Apply the desired state instead of silently reporting success. Both + // exposed props are mutable in place via UpdateRegistry (the registry id + // is stable across a rename), so no replacement is needed — the physical + // id is unchanged. Previously this branch sent no SDK command, so a + // changed RegistryName/Description left CloudFormation reporting success + // while the managed registry kept its old values. + const registryId = event.PhysicalResourceId!; + const { RegistryName, Description } = event.ResourceProperties; + const old = event.OldResourceProperties ?? {}; + const nameChanged = RegistryName !== old.RegistryName; + const descChanged = Description !== old.Description; + if (nameChanged || descChanged) { + await client.send( + new UpdateRegistryCommand({ + registryId, + ...(nameChanged && { name: RegistryName }), + // The description update is a wrapper: an absent optionalValue clears it. + ...(descChanged && { description: { optionalValue: Description } }), + }), + ); + } + return { PhysicalResourceId: registryId }; } case 'Delete': { const registryId = event.PhysicalResourceId!; diff --git a/cdk/src/handlers/registry-publish.ts b/cdk/src/handlers/registry-publish.ts index 9ece57e1a..703c806f3 100644 --- a/cdk/src/handlers/registry-publish.ts +++ b/cdk/src/handlers/registry-publish.ts @@ -28,7 +28,7 @@ import { makeRegistryClient, } from './shared/registry/factory'; import { REGISTRY_KINDS, RESERVED_KINDS, parseConstraint } from './shared/registry/ref'; -import type { PublishInput, RuntimePayload } from './shared/registry/types'; +import { RegistryPublishIncompleteError, type PublishInput, type RuntimePayload } from './shared/registry/types'; import { ErrorCode, errorResponse, successResponse } from './shared/response'; import type { RegistryPublishRequest, RegistryRecordResponse } from './shared/types'; @@ -73,6 +73,7 @@ export async function handler(event: APIGatewayProxyEvent): Promise { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +/** + * Enforce the discriminated runtime contract per kind (#246 review). Previously + * `runtime` was only checked as `typeof object`, so arrays, `{}`, and wrong-kind + * payloads published a 201 that later resolved into a loader that silently + * skipped it — while the task audit still claimed the pin was used. Reject those + * at publish so a published record's runtime is always loadable. + */ +function validateRuntime(kind: string, runtime: Record): string | null { + switch (kind) { + case 'mcp_server': { + const transport = runtime.transport; + if (transport !== 'http' && transport !== 'sse' && transport !== 'stdio') { + return "mcp_server runtime.transport must be one of 'http', 'sse', 'stdio'."; + } + if (transport === 'stdio') { + if (typeof runtime.command !== 'string' || !runtime.command) { + return "mcp_server runtime.command (non-empty string) is required for transport 'stdio'."; + } + } else if (typeof runtime.url !== 'string' || !runtime.url) { + return `mcp_server runtime.url (non-empty string) is required for transport '${transport}'.`; + } + if (runtime.headers !== undefined && !isPlainObject(runtime.headers)) { + return 'mcp_server runtime.headers, when present, must be a JSON object.'; + } + return null; + } + case 'cedar_policy_module': + if (typeof runtime.cedar_text !== 'string' || !runtime.cedar_text.trim()) { + return 'cedar_policy_module runtime.cedar_text (non-empty string) is required.'; + } + return null; + case 'skill': + if (typeof runtime.prompt_fragment !== 'string' || !runtime.prompt_fragment.trim()) { + return 'skill runtime.prompt_fragment (non-empty string) is required.'; + } + if (runtime.tool_hints !== undefined && !Array.isArray(runtime.tool_hints)) { + return 'skill runtime.tool_hints, when present, must be an array.'; + } + return null; + default: + // Unknown kinds are already rejected above; defensive fallthrough. + return `no runtime contract defined for kind '${kind}'.`; } - return null; } diff --git a/cdk/src/handlers/registry-resolve.ts b/cdk/src/handlers/registry-resolve.ts index 63629ed34..624ee0ca0 100644 --- a/cdk/src/handlers/registry-resolve.ts +++ b/cdk/src/handlers/registry-resolve.ts @@ -27,6 +27,40 @@ import { RegistryResolutionError } from './shared/registry/types'; import { ErrorCode, errorResponse, successResponse } from './shared/response'; import type { RegistryResolveResponse } from './shared/types'; +/** + * Redact secret-bearing fields from a runtime payload before returning it on the + * human-facing resolve response. An mcp_server payload may carry secrets in two + * places: `headers` (e.g. `Authorization: Bearer …`) on http/sse transports, and + * `command`/`args` on a `stdio` transport (tokens are routinely passed as CLI + * args such as `--api-key=…` or embedded in the command string). This endpoint is + * open to any authenticated caller (resolve/read is not group-gated, + * REGISTRY.md §10), so returning either verbatim would turn the catalog into a + * tenant-wide secret-read endpoint (#246 review). Header *keys* are retained as + * discovery signal — a caller can see the server expects an `Authorization` + * header without learning its value; `command`/`args` are masked wholesale + * because their structure itself can encode secrets. The orchestrator does NOT go + * through this handler: it resolves via the `RegistryClient` port directly and + * receives the unredacted payload it needs to connect. + */ +function redactRuntimeForResponse(runtime: Record): Record { + const out = { ...runtime }; + const headers = out.headers; + if (headers && typeof headers === 'object' && !Array.isArray(headers)) { + const redacted: Record = {}; + for (const key of Object.keys(headers as Record)) { + redacted[key] = '***'; + } + out.headers = redacted; + } + if (typeof out.command === 'string') { + out.command = '***'; + } + if (Array.isArray(out.args)) { + out.args = (out.args as unknown[]).map(() => '***'); + } + return out; +} + /** * GET /v1/registry/resolve?ref=registry://kind/namespace/name@constraint * @@ -59,7 +93,7 @@ export async function handler(event: APIGatewayProxyEvent): Promise, + runtime: redactRuntimeForResponse(asset.runtime as unknown as Record), warnings: asset.warnings, }; return successResponse(200, response, requestId); diff --git a/cdk/src/handlers/registry-show.ts b/cdk/src/handlers/registry-show.ts index d40c699a8..c0a2ed8b0 100644 --- a/cdk/src/handlers/registry-show.ts +++ b/cdk/src/handlers/registry-show.ts @@ -24,7 +24,7 @@ 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'; +import type { RegistryShowResponse, RegistryVersionSummary } from './shared/types'; /** * GET /v1/registry/records/{kind}/{namespace}/{name} — show every version of @@ -65,7 +65,8 @@ export async function handler(event: APIGatewayProxyEvent): Promise).length > 0 + ); +} + /** Kinds that map onto a native AgentCore descriptor type. */ const NATIVE_DESCRIPTOR_BY_KIND: Record = { mcp_server: 'MCP', @@ -93,29 +109,54 @@ function buildSkillMd(input: { version: string; discovery: Readonly>; runtime: unknown; + publisher?: string; }): 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 [ + // Base64-encode the runtime JSON. Emitting raw JSON in a single-quoted YAML + // scalar breaks the moment the payload contains a `'` (e.g. prompt_fragment + // "Don't skip tests") — js-yaml rejects the frontmatter and native AgentCore + // descriptor validation fails, even though the ABCA API accepted it (#246 + // review). Base64 is quote/newline/apostrophe-safe and needs no YAML escaping. + const runtimeB64 = Buffer.from(JSON.stringify(input.runtime), 'utf-8').toString('base64'); + const lines = [ '---', `name: ${skillNameSlug(input.namespace, input.name)}`, `description: ${description}`, `version: ${input.version}`, - `${SKILL_RUNTIME_FM_KEY}: '${runtimeJson}'`, + `${SKILL_RUNTIME_FM_KEY}: ${runtimeB64}`, + ]; + if (input.publisher) lines.push(`${PUBLISHER_FM_KEY}: ${input.publisher}`); + lines.push( '---', `# ${input.namespace}/${input.name}`, '', String(input.discovery.body ?? 'ABCA registry skill.'), - ].join('\n'); + ); + return lines.join('\n'); +} + +/** Recover the publisher (Cognito sub) from a SKILL.md frontmatter line. */ +function parseSkillPublisher(skillMd: string): string | undefined { + const m = skillMd.match(new RegExp(`^${PUBLISHER_FM_KEY}:\\s*(.+?)\\s*$`, 'm')); + return m ? m[1] : undefined; } /** Recover the ABCA runtime payload from a SKILL.md's `x-abca-runtime` - * frontmatter line. Mirrors ``agent/src/registry/agentcore_client.py``. */ + * frontmatter line (base64-encoded JSON). Mirrors + * ``agent/src/registry/agentcore_client.py``. Also accepts the legacy + * single-quoted-JSON form so records published before the base64 switch still + * resolve. */ function parseSkillRuntime(skillMd: string): unknown { - const m = skillMd.match(new RegExp(`^${SKILL_RUNTIME_FM_KEY}:\\s*'(.+)'\\s*$`, 'm')); - return m ? JSON.parse(m[1]) : {}; + const line = skillMd.match(new RegExp(`^${SKILL_RUNTIME_FM_KEY}:\\s*(.+?)\\s*$`, 'm')); + if (!line) return {}; + const raw = line[1]; + // Legacy form: '' (single-quoted). New form: bare base64. + if (raw.startsWith("'") && raw.endsWith("'")) { + return JSON.parse(raw.slice(1, -1)); + } + return JSON.parse(Buffer.from(raw, 'base64').toString('utf-8')); } export interface AgentCoreRegistryClientOptions { @@ -165,44 +206,60 @@ export class AgentCoreRegistryClient implements RegistryClient { ? { 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; - } + 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, + }), + ); + const recordId = this.idFromArn(res.recordArn!); - // CreateRegistryRecord is async — wait until it leaves CREATING. - await this.waitPastCreating(recordId); + // The record now exists on the substrate. Any failure past this point leaves + // a partial (DRAFT/PENDING_APPROVAL) record that immutability will block a + // clean retry of — so surface the recordId in a typed error rather than a + // bare 500, and log it, so an operator can approve or delete the orphan. + try { + // CreateRegistryRecord is async — wait until it leaves CREATING. + await this.waitPastCreating(recordId); - if (input.autoApprove) { - // DRAFT -> PENDING_APPROVAL -> APPROVED (submit is a mandatory waypoint). + // Always submit for approval so a normal publish lands in PENDING_APPROVAL — + // otherwise the record sits in DRAFT, which no ABCA surface can resolve or + // promote (there is no standalone submit endpoint). Only the final APPROVED + // transition is gated on autoApprove. 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', - }), + if (input.autoApprove) { + 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; + } catch (err) { + logger.error('registry publish incomplete — partial record stranded', { + recordId, + name, + version: input.version, + error: String(err), + }); + throw new RegistryPublishIncompleteError( + recordId, + `record ${name}@${input.version} was created (id ${recordId}) but could not be ` + + 'driven to a resolvable state; approve or delete it before retrying', + err, ); } - - const record = await this.getRecordById(recordId); - if (!record) throw new Error(`published record ${recordId} not readable after write`); - return record; } // --- get / list ------------------------------------------------------------- @@ -220,6 +277,10 @@ export class AgentCoreRegistryClient implements RegistryClient { } async listRecords(filter?: ListFilter): Promise { + // TODO(GA): O(n) — List + one GetRegistryRecord per summary, and every read + // path (resolve/show/getRecord) funnels through here. Fine at MVP catalog + // sizes; revisit when the native AgentCore construct lands (server-side + // filter / batch get) so large catalogs don't pay a per-record round trip. const out: RegistryRecord[] = []; let nextToken: string | undefined; do { @@ -267,6 +328,18 @@ export class AgentCoreRegistryClient implements RegistryClient { ); } const winner = candidates.find((r) => r.version === winningVersion)!; + // Fail closed: an otherwise-resolvable record whose runtime payload is + // empty/unreadable must NOT resolve to `{}` — that would let a task run with + // a missing/substituted asset while the audit claims the pin was honored + // (REGISTRY.md §8). A record can reach this state via an out-of-band write or + // a corrupt `_meta`/CUSTOM body that slipped past publish validation. + if (!isNonEmptyRuntime(winner.runtime)) { + throw new RegistryResolutionError( + 'REMOVED', + refStr, + `resolved ${winner.kind}/${winner.namespace}/${winner.name}@${winner.version} has no loadable runtime payload`, + ); + } const warnings = winner.status === 'DEPRECATED' ? ['DEPRECATED'] : []; return { kind: winner.kind, @@ -284,19 +357,43 @@ export class AgentCoreRegistryClient implements RegistryClient { return arn.includes('/') ? arn.split('/').pop()! : arn; } + /** + * Poll a freshly-created record until it settles into a usable state. + * + * CreateRegistryRecord is async, so we must confirm the substrate actually + * accepted the record before treating publish as successful. Prior behavior + * returned on *any* non-`CREATING` status (so `CREATE_FAILED` looked like + * success), treated a not-found as success (it's transient right after + * create), and treated poll-budget exhaustion as success — any of which let + * the handler return 201 for a record that never became usable (#246 review). + * + * Now: not-found and `CREATING` are transient (keep polling); any `*_FAILED` + * status throws with the substrate's statusReason; reaching a usable state + * (`DRAFT`/`PENDING_APPROVAL`/`APPROVED`) returns; exhausting the budget throws. + */ 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; + const status = String(rec.status ?? ''); + if (status.endsWith('_FAILED')) { + throw new Error( + `record ${recordId} entered ${status}${rec.statusReason ? `: ${rec.statusReason}` : ''}`, + ); + } + if (status && status !== 'CREATING') return; // DRAFT / PENDING_APPROVAL / APPROVED } catch (err) { - if (err instanceof ResourceNotFoundException) return; - throw err; + // Transient right after CreateRegistryRecord — the record may not be + // readable yet. Keep polling rather than declaring success. + if (!(err instanceof ResourceNotFoundException)) throw err; } await sleep(RECORD_CREATE_POLL_MS); } + throw new Error( + `record ${recordId} did not leave CREATING within ${RECORD_CREATE_MAX_POLLS} polls`, + ); } private async getRecordById(recordId: string): Promise { @@ -310,7 +407,7 @@ export class AgentCoreRegistryClient implements RegistryClient { throw err; } const decoded = this.decodeName(raw.name ?? ''); - const { runtime, storageMode, discovery } = this.extractPayload(raw); + const { runtime, storageMode, discovery, publisher } = this.extractPayload(raw); return { kind: decoded.kind, namespace: decoded.namespace, @@ -320,6 +417,7 @@ export class AgentCoreRegistryClient implements RegistryClient { storageMode, discovery, runtime, + publisher, createdAt: raw.createdAt ? raw.createdAt.toISOString() : undefined, }; } @@ -333,13 +431,19 @@ export class AgentCoreRegistryClient implements RegistryClient { mcp?: { server?: { inlineContent?: string } }; agentSkills?: { skillMd?: { inlineContent?: string } }; }; - }): { runtime: RuntimePayload; storageMode: StorageMode; discovery: Record } { + }): { + runtime: RuntimePayload; + storageMode: StorageMode; + discovery: Record; + publisher?: string; + } { 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, + publisher: typeof body.publisher === 'string' ? body.publisher : undefined, }; } if (raw.descriptorType === 'AGENT_SKILLS') { @@ -350,16 +454,19 @@ export class AgentCoreRegistryClient implements RegistryClient { runtime: parseSkillRuntime(skillMd) as RuntimePayload, storageMode: 'native', discovery: { skillMd }, + publisher: parseSkillPublisher(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]; + const publisher = body._meta?.[PUBLISHER_META_KEY]; return { runtime: meta as RuntimePayload, storageMode: 'native', discovery: body as Record, + publisher: typeof publisher === 'string' ? publisher : undefined, }; } @@ -368,6 +475,7 @@ export class AgentCoreRegistryClient implements RegistryClient { abca_kind: input.kind, discovery: input.discovery, runtime: input.runtime, + ...(input.publisher && { publisher: input.publisher }), }; } @@ -376,8 +484,19 @@ export class AgentCoreRegistryClient implements RegistryClient { 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 } }; + // MCP: embed the runtime + publisher in a `_meta` block on the validated + // server.json. A valid server.json may legitimately carry its own `_meta` + // (the MCP spec reserves it for arbitrary metadata), so merge our ABCA keys + // into the caller's block rather than replacing it — clobbering it would + // silently drop the publisher's metadata, and a future reorder could drop + // our runtime on read (extractPayload reads `_meta[RUNTIME_META_KEY]`). + const callerMeta = + input.discovery._meta && typeof input.discovery._meta === 'object' && !Array.isArray(input.discovery._meta) + ? (input.discovery._meta as Record) + : {}; + const meta: Record = { ...callerMeta, [RUNTIME_META_KEY]: input.runtime }; + if (input.publisher) meta[PUBLISHER_META_KEY] = input.publisher; + const withMeta = { ...input.discovery, _meta: meta }; return { mcp: { server: { inlineContent: JSON.stringify(withMeta) } } }; } // AGENT_SKILLS: the validator requires Markdown frontmatter (not JSON), so @@ -388,6 +507,7 @@ export class AgentCoreRegistryClient implements RegistryClient { version: input.version, discovery: input.discovery, runtime: input.runtime, + publisher: input.publisher, }); return { agentSkills: { skillMd: { inlineContent: skillMd } } }; } diff --git a/cdk/src/handlers/shared/registry/ref.ts b/cdk/src/handlers/shared/registry/ref.ts index cedbcdf89..64bc8bd68 100644 --- a/cdk/src/handlers/shared/registry/ref.ts +++ b/cdk/src/handlers/shared/registry/ref.ts @@ -84,11 +84,20 @@ const OP_BY_PREFIX: Record = { '': 'exact', '^': 'caret', export function parseConstraint(raw: string): ParsedConstraint | null { const m = CONSTRAINT.exec(raw); if (!m) return null; + const major = Number(m[2]); + const minor = Number(m[3]); + const patch = Number(m[4]); + // Reject components beyond MAX_SAFE_INTEGER: `Number()` rounds them, so TS + // would silently compare a different value than Python's arbitrary-precision + // int — a cross-language parity break in version selection (#246 review). + if (!Number.isSafeInteger(major) || !Number.isSafeInteger(minor) || !Number.isSafeInteger(patch)) { + return null; + } return { op: OP_BY_PREFIX[m[1]], - major: Number(m[2]), - minor: Number(m[3]), - patch: Number(m[4]), + major, + minor, + patch, prerelease: m[5], raw, }; diff --git a/cdk/src/handlers/shared/registry/resolver.ts b/cdk/src/handlers/shared/registry/resolver.ts index 5b90ce220..1e219f8d6 100644 --- a/cdk/src/handlers/shared/registry/resolver.ts +++ b/cdk/src/handlers/shared/registry/resolver.ts @@ -39,10 +39,18 @@ const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z.-]+))?$ export function parseVersion(raw: string): SemVer | null { const m = SEMVER.exec(raw); if (!m) return null; + const major = Number(m[1]); + const minor = Number(m[2]); + const patch = Number(m[3]); + // Match parseConstraint: reject components `Number()` would round past + // MAX_SAFE_INTEGER, so a candidate version compares identically in TS and Py. + if (!Number.isSafeInteger(major) || !Number.isSafeInteger(minor) || !Number.isSafeInteger(patch)) { + return null; + } return { - major: Number(m[1]), - minor: Number(m[2]), - patch: Number(m[3]), + major, + minor, + patch, prerelease: m[4] ? m[4].split('.') : [], raw, }; diff --git a/cdk/src/handlers/shared/registry/types.ts b/cdk/src/handlers/shared/registry/types.ts index 32cdcf56e..a03c70fae 100644 --- a/cdk/src/handlers/shared/registry/types.ts +++ b/cdk/src/handlers/shared/registry/types.ts @@ -45,6 +45,15 @@ export type StorageMode = 'native' | 'custom'; * MCP `server.json` `_meta` block (spike-verified to survive validation). */ export const RUNTIME_META_KEY = 'dev.abca.runtime'; +/** Companion `_meta` key carrying the authenticated publisher (Cognito sub) so + * the record's origin is reconstructable — CloudTrail only sees the shared + * Lambda role. Written once at publish; immutable with the rest of the record. */ +export const PUBLISHER_META_KEY = 'dev.abca.publisher'; + +/** Frontmatter key carrying the publisher inside a native AGENT_SKILLS SKILL.md + * (skills can't hold a `_meta` block). */ +export const PUBLISHER_FM_KEY = 'x-abca-publisher'; + // --- Per-kind runtime payloads ------------------------------------------------- // The loadable body each kind carries, independent of discovery metadata. @@ -126,6 +135,25 @@ export class RegistryResolutionError extends Error { } } +/** + * Raised when `publish` succeeds at CreateRegistryRecord but a later step + * (submit/approve/re-read) fails, leaving a partial record on the substrate. + * Carries the `recordId` so the operator can find and approve or delete the + * orphan — a bare 500 hid that a record was stranded, and because immutability + * rejects re-publishing the same version, a retry would otherwise 409 forever + * with no resolvable record (#246 review). + */ +export class RegistryPublishIncompleteError extends Error { + constructor( + readonly recordId: string, + message: string, + readonly cause?: unknown, + ) { + super(message); + this.name = 'RegistryPublishIncompleteError'; + } +} + // --- Port input types ---------------------------------------------------------- export interface PublishInput { @@ -135,6 +163,9 @@ export interface PublishInput { readonly version: string; readonly discovery: Readonly>; readonly runtime: RuntimePayload; + /** Authenticated publisher (Cognito sub) — stamped immutably on the record for + * audit. Optional only so non-HTTP callers/tests can omit it. */ + readonly publisher?: string; /** Force CUSTOM storage (verbatim) instead of a native descriptor. */ readonly custom?: boolean; /** Dev convenience: drive create → submit → approve so the record resolves. */ diff --git a/cdk/src/handlers/shared/response.ts b/cdk/src/handlers/shared/response.ts index 740bc006b..1a315cada 100644 --- a/cdk/src/handlers/shared/response.ts +++ b/cdk/src/handlers/shared/response.ts @@ -60,6 +60,7 @@ export const ErrorCode = { REGISTRY_VERSION_EXISTS: 'REGISTRY_VERSION_EXISTS', REGISTRY_RESOLUTION_FAILED: 'REGISTRY_RESOLUTION_FAILED', REGISTRY_RECORD_NOT_FOUND: 'REGISTRY_RECORD_NOT_FOUND', + REGISTRY_PUBLISH_INCOMPLETE: 'REGISTRY_PUBLISH_INCOMPLETE', } as const; const COMMON_HEADERS = { diff --git a/cdk/src/handlers/shared/types.ts b/cdk/src/handlers/shared/types.ts index 2a33b3f8d..a19da81b4 100644 --- a/cdk/src/handlers/shared/types.ts +++ b/cdk/src/handlers/shared/types.ts @@ -90,6 +90,16 @@ export type RegistryVersionSummary = { readonly publisher: string | null; }; +/** `GET /registry/records/{kind}/{namespace}/{name}` response — one asset's + * versions. Named (rather than inlined on the handler + client) so it rides the + * CDK↔CLI types-sync guard like the other registry envelopes. */ +export type RegistryShowResponse = { + readonly kind: string; + readonly namespace: string; + readonly name: string; + readonly versions: readonly RegistryVersionSummary[]; +}; + /** A record envelope returned by publish / show. */ export type RegistryRecordResponse = { readonly kind: string; diff --git a/cdk/test/constructs/registry.test.ts b/cdk/test/constructs/registry.test.ts index 9edd411a1..a35e65855 100644 --- a/cdk/test/constructs/registry.test.ts +++ b/cdk/test/constructs/registry.test.ts @@ -90,6 +90,7 @@ describe('AgentRegistry construct', () => { Match.objectLike({ Action: Match.arrayWith([ 'bedrock-agentcore:GetRegistry', + 'bedrock-agentcore:UpdateRegistry', '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 index 44ca5f3e6..1f9bd23d2 100644 --- a/cdk/test/handlers/registry-handlers.test.ts +++ b/cdk/test/handlers/registry-handlers.test.ts @@ -149,6 +149,56 @@ describe('registry-publish handler', () => { expect(res.statusCode).toBe(409); expect(JSON.parse(res.body).error.code).toBe('REGISTRY_VERSION_EXISTS'); }); + + // Per-kind runtime contract (#246 review): runtime was previously only checked + // as `typeof object`, so arrays / empty / wrong-kind payloads published a 201 + // and later resolved into a loader that silently skipped them. + describe('per-kind runtime validation (400, publish never called)', () => { + const publishAs = async (body: Record): Promise => { + const res = await publishHandler(makeEvent({ + requestContext: { ...makeEvent().requestContext, authorizer: withGroups(['RegistryPublisher']) }, + body: JSON.stringify(body), + })); + return res.statusCode; + }; + + test('rejects an array runtime', async () => { + expect(await publishAs({ ...validPublishBody, runtime: [] })).toBe(400); + expect(mockClient.publish).not.toHaveBeenCalled(); + }); + + test('rejects an empty mcp_server runtime', async () => { + expect(await publishAs({ ...validPublishBody, runtime: {} })).toBe(400); + }); + + test('rejects mcp_server http without a url', async () => { + expect(await publishAs({ ...validPublishBody, runtime: { transport: 'http' } })).toBe(400); + }); + + test('rejects mcp_server with a bad transport', async () => { + expect(await publishAs({ ...validPublishBody, runtime: { transport: 'grpc', url: 'https://x' } })).toBe(400); + }); + + test('rejects a cedar_policy_module without cedar_text', async () => { + expect(await publishAs({ + ...validPublishBody, kind: 'cedar_policy_module', runtime: { prompt_fragment: 'x' }, + })).toBe(400); + }); + + test('rejects a skill without prompt_fragment', async () => { + expect(await publishAs({ + ...validPublishBody, kind: 'skill', runtime: { transport: 'http', url: 'https://x' }, + })).toBe(400); + }); + + test('accepts a well-formed stdio mcp_server (command, no url)', 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, + }); + expect(await publishAs({ ...validPublishBody, runtime: { transport: 'stdio', command: 'run-me' } })).toBe(201); + }); + }); }); describe('registry-resolve handler', () => { @@ -185,6 +235,52 @@ describe('registry-resolve handler', () => { expect(res.statusCode).toBe(422); expect(JSON.parse(res.body).error.message).toContain('NO_MATCHING_VERSION'); }); + + test('redacts secret header values but keeps header keys (#246 secret-leak fix)', async () => { + mockClient.resolve.mockResolvedValue({ + kind: 'mcp_server', + namespace: 'acme', + name: 'pdf-tools', + version: '1.4.1', + runtime: { + transport: 'http', + url: 'https://x', + headers: { Authorization: 'Bearer registry-secret', 'X-Api-Key': 'topsecret' }, + } as never, + warnings: [], + }); + const res = await resolveHandler(ev('registry://mcp_server/acme/pdf-tools@^1.4.1')); + expect(res.statusCode).toBe(200); + const { runtime } = JSON.parse(res.body).data; + // Header keys survive (discovery signal); values are masked. + expect(runtime.headers).toEqual({ Authorization: '***', 'X-Api-Key': '***' }); + expect(JSON.stringify(res.body)).not.toContain('registry-secret'); + expect(JSON.stringify(res.body)).not.toContain('topsecret'); + // Non-secret fields are untouched. + expect(runtime.url).toBe('https://x'); + }); + + test('redacts stdio command and args (secrets are routinely passed as CLI args)', async () => { + mockClient.resolve.mockResolvedValue({ + kind: 'mcp_server', + namespace: 'acme', + name: 'pdf-tools', + version: '1.4.1', + runtime: { + transport: 'stdio', + command: 'run-secret-server', + args: ['--api-key=topsecret', '--verbose'], + } as never, + warnings: [], + }); + const res = await resolveHandler(ev('registry://mcp_server/acme/pdf-tools@^1.4.1')); + expect(res.statusCode).toBe(200); + const { runtime } = JSON.parse(res.body).data; + expect(runtime.command).toBe('***'); + expect(runtime.args).toEqual(['***', '***']); + expect(JSON.stringify(res.body)).not.toContain('topsecret'); + expect(JSON.stringify(res.body)).not.toContain('run-secret-server'); + }); }); describe('registry-list handler', () => { diff --git a/cdk/test/handlers/registry-provisioning/index.test.ts b/cdk/test/handlers/registry-provisioning/index.test.ts new file mode 100644 index 000000000..ec3b54400 --- /dev/null +++ b/cdk/test/handlers/registry-provisioning/index.test.ts @@ -0,0 +1,243 @@ +/** + * 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. + */ + +/** + * Unit tests for the registry provisioning custom-resource handlers (#246): + * onEvent (Create/Update/Delete) and isComplete (Create/Update/Delete). The + * handler drives an async AgentCore registry lifecycle, so these lock in the + * non-obvious branches the source comments flag as prior bugs: idempotent + * create tokens, the Update branch actually issuing UpdateRegistry, and the + * delete drain + Conflict/NotFound retry semantics. + */ + +// Command classes are tagged so the mock `send` can dispatch on constructor. +// The two exception classes must be real (throwable) classes because the +// handler branches on `instanceof`. +const mockSend = jest.fn(); + +class ConflictException extends Error { + constructor() { + super('conflict'); + this.name = 'ConflictException'; + } +} +class ResourceNotFoundException extends Error { + constructor() { + super('not found'); + this.name = 'ResourceNotFoundException'; + } +} + +jest.mock('@aws-sdk/client-bedrock-agentcore-control', () => ({ + BedrockAgentCoreControlClient: jest.fn(() => ({ send: mockSend })), + CreateRegistryCommand: jest.fn((input: unknown) => ({ _type: 'CreateRegistry', input })), + GetRegistryCommand: jest.fn((input: unknown) => ({ _type: 'GetRegistry', input })), + UpdateRegistryCommand: jest.fn((input: unknown) => ({ _type: 'UpdateRegistry', input })), + DeleteRegistryCommand: jest.fn((input: unknown) => ({ _type: 'DeleteRegistry', input })), + ListRegistryRecordsCommand: jest.fn((input: unknown) => ({ _type: 'ListRegistryRecords', input })), + DeleteRegistryRecordCommand: jest.fn((input: unknown) => ({ _type: 'DeleteRegistryRecord', input })), + ConflictException, + ResourceNotFoundException, +})); + +import { isComplete, onEvent } from '../../../src/handlers/registry-provisioning/index'; + +interface TaggedCommand { + _type: string; + input: Record; +} + +beforeEach(() => { + mockSend.mockReset(); +}); + +/** Route mockSend by command type using the provided handlers. */ +function routeSend(handlers: Record) => unknown>): void { + mockSend.mockImplementation((cmd: TaggedCommand) => { + const h = handlers[cmd._type]; + if (!h) throw new Error(`unexpected command ${cmd._type}`); + return Promise.resolve(h(cmd.input)); + }); +} + +const ARN = 'arn:aws:bedrock-agentcore:us-east-1:1:registry/reg-123'; + +describe('onEvent Create', () => { + test('creates the registry and returns the id as PhysicalResourceId', async () => { + routeSend({ CreateRegistry: () => ({ registryArn: ARN }) }); + const res = await onEvent({ + RequestType: 'Create', + RequestId: 'req-1', + ResourceProperties: { RegistryName: 'abca', Description: 'd' }, + }); + expect(res.PhysicalResourceId).toBe('reg-123'); + expect(res.Data).toMatchObject({ RegistryId: 'reg-123', RegistryArn: ARN }); + const createInput = mockSend.mock.calls[0][0].input as Record; + expect(createInput.name).toBe('abca'); + expect(typeof createInput.clientToken).toBe('string'); + }); + + test('clientToken is deterministic per RequestId (idempotent retry) and varies across RequestIds', async () => { + routeSend({ CreateRegistry: () => ({ registryArn: ARN }) }); + const props = { RegistryName: 'abca' }; + await onEvent({ RequestType: 'Create', RequestId: 'req-1', ResourceProperties: props }); + await onEvent({ RequestType: 'Create', RequestId: 'req-1', ResourceProperties: props }); + await onEvent({ RequestType: 'Create', RequestId: 'req-2', ResourceProperties: props }); + const token = (n: number) => (mockSend.mock.calls[n][0].input as Record).clientToken; + expect(token(0)).toBe(token(1)); // same RequestId → same token → substrate no-op on retry + expect(token(0)).not.toBe(token(2)); // different RequestId → different token + }); +}); + +describe('onEvent Update', () => { + test('sends UpdateRegistry with only the changed name', async () => { + routeSend({ UpdateRegistry: () => ({}) }); + await onEvent({ + RequestType: 'Update', + PhysicalResourceId: 'reg-123', + ResourceProperties: { RegistryName: 'new-name', Description: 'same' }, + OldResourceProperties: { RegistryName: 'old-name', Description: 'same' }, + }); + expect(mockSend).toHaveBeenCalledTimes(1); + const input = mockSend.mock.calls[0][0].input as Record; + expect(input.name).toBe('new-name'); + expect(input.description).toBeUndefined(); // description unchanged → not sent + }); + + test('clears the description via the optionalValue wrapper when it is removed', async () => { + routeSend({ UpdateRegistry: () => ({}) }); + await onEvent({ + RequestType: 'Update', + PhysicalResourceId: 'reg-123', + ResourceProperties: { RegistryName: 'abca' }, + OldResourceProperties: { RegistryName: 'abca', Description: 'was here' }, + }); + const input = mockSend.mock.calls[0][0].input as Record; + expect(input.description).toEqual({ optionalValue: undefined }); + }); + + test('sends no SDK command when nothing changed', async () => { + routeSend({}); + await onEvent({ + RequestType: 'Update', + PhysicalResourceId: 'reg-123', + ResourceProperties: { RegistryName: 'abca', Description: 'd' }, + OldResourceProperties: { RegistryName: 'abca', Description: 'd' }, + }); + expect(mockSend).not.toHaveBeenCalled(); + }); +}); + +describe('onEvent Delete', () => { + test('drains records then deletes the registry', async () => { + routeSend({ + ListRegistryRecords: () => ({ registryRecords: [{ recordId: 'r1' }, { recordId: 'r2' }] }), + DeleteRegistryRecord: () => ({}), + DeleteRegistry: () => ({}), + }); + await onEvent({ RequestType: 'Delete', PhysicalResourceId: 'reg-123', ResourceProperties: { RegistryName: 'abca' } }); + const types = mockSend.mock.calls.map((c) => (c[0] as TaggedCommand)._type); + expect(types).toEqual(['ListRegistryRecords', 'DeleteRegistryRecord', 'DeleteRegistryRecord', 'DeleteRegistry']); + }); + + test('swallows a ConflictException on DeleteRegistry (isComplete will retry)', async () => { + routeSend({ + ListRegistryRecords: () => ({ registryRecords: [] }), + DeleteRegistry: () => { + throw new ConflictException(); + }, + }); + await expect( + onEvent({ RequestType: 'Delete', PhysicalResourceId: 'reg-123', ResourceProperties: { RegistryName: 'abca' } }), + ).resolves.toMatchObject({ PhysicalResourceId: 'reg-123' }); + }); + + test('rethrows an unexpected error from DeleteRegistry', async () => { + routeSend({ + ListRegistryRecords: () => ({ registryRecords: [] }), + DeleteRegistry: () => { + throw new Error('AccessDenied'); + }, + }); + await expect( + onEvent({ RequestType: 'Delete', PhysicalResourceId: 'reg-123', ResourceProperties: { RegistryName: 'abca' } }), + ).rejects.toThrow('AccessDenied'); + }); +}); + +describe('isComplete Create/Update', () => { + test('returns IsComplete once the registry is READY', async () => { + routeSend({ GetRegistry: () => ({ status: 'READY', registryArn: ARN }) }); + const res = await isComplete({ + RequestType: 'Create', + PhysicalResourceId: 'reg-123', + ResourceProperties: { RegistryName: 'abca' }, + }); + expect(res).toMatchObject({ IsComplete: true, Data: { RegistryId: 'reg-123', RegistryArn: ARN } }); + }); + + test('keeps polling while still CREATING', async () => { + routeSend({ GetRegistry: () => ({ status: 'CREATING' }) }); + const res = await isComplete({ + RequestType: 'Create', + PhysicalResourceId: 'reg-123', + ResourceProperties: { RegistryName: 'abca' }, + }); + expect(res.IsComplete).toBe(false); + }); + + test('throws (fails the deploy) on a FAILED status with the substrate reason', async () => { + routeSend({ GetRegistry: () => ({ status: 'CREATE_FAILED', statusReason: 'quota exceeded' }) }); + await expect( + isComplete({ RequestType: 'Create', PhysicalResourceId: 'reg-123', ResourceProperties: { RegistryName: 'abca' } }), + ).rejects.toThrow(/CREATE_FAILED.*quota exceeded/); + }); +}); + +describe('isComplete Delete', () => { + test('is complete once GetRegistry 404s (registry gone)', async () => { + routeSend({ + GetRegistry: () => { + throw new ResourceNotFoundException(); + }, + }); + const res = await isComplete({ + RequestType: 'Delete', + PhysicalResourceId: 'reg-123', + ResourceProperties: { RegistryName: 'abca' }, + }); + expect(res.IsComplete).toBe(true); + }); + + test('not complete while the registry still exists — drains + retries delete', async () => { + routeSend({ + GetRegistry: () => ({ status: 'READY' }), + ListRegistryRecords: () => ({ registryRecords: [] }), + DeleteRegistry: () => ({}), + }); + const res = await isComplete({ + RequestType: 'Delete', + PhysicalResourceId: 'reg-123', + ResourceProperties: { RegistryName: 'abca' }, + }); + expect(res.IsComplete).toBe(false); + const types = mockSend.mock.calls.map((c) => (c[0] as TaggedCommand)._type); + expect(types).toContain('DeleteRegistry'); + }); +}); diff --git a/cdk/test/handlers/shared/agentcore-client.test.ts b/cdk/test/handlers/shared/agentcore-client.test.ts index 19ace09a7..0f6592560 100644 --- a/cdk/test/handlers/shared/agentcore-client.test.ts +++ b/cdk/test/handlers/shared/agentcore-client.test.ts @@ -26,7 +26,7 @@ import { } 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'; +import { RegistryPublishIncompleteError, RegistryResolutionError } from '../../../src/handlers/shared/registry/types'; const RUNTIME_META_KEY = 'dev.abca.runtime'; @@ -36,6 +36,14 @@ class FakeClient { private records = new Map>(); private seq = 0; public sent: string[] = []; + /** Status a CREATING record settles into on the first Get. `'CREATING'` + * simulates a record that never settles (poll-budget timeout). */ + public settleStatus = 'DRAFT'; + /** Optional statusReason surfaced alongside a *_FAILED settle. */ + public settleReason?: string; + /** When true, SubmitRegistryRecordForApproval throws — simulates a post-create + * failure that strands a DRAFT record. */ + public failSubmit = false; seed(record: Record): string { const id = `rec-${++this.seq}`; @@ -76,8 +84,13 @@ class FakeClient { 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'; + // Simulate async settle: first Get after create flips CREATING → the + // configured settle status (DRAFT by default; CREATE_FAILED or a stuck + // CREATING for the failure/timeout tests). + if (rec && rec.status === 'CREATING' && this.settleStatus !== 'CREATING') { + rec.status = this.settleStatus; + if (this.settleReason) rec.statusReason = this.settleReason; + } return rec ?? {}; } if (cmd instanceof ListRegistryRecordsCommand) { @@ -85,6 +98,7 @@ class FakeClient { } if (cmd instanceof SubmitRegistryRecordForApprovalCommand) { this.sent.push('submit'); + if (this.failSubmit) throw new Error('submit rejected by substrate'); const id = (cmd.input as { recordId: string }).recordId; const rec = this.records.get(id); if (rec) rec.status = 'PENDING_APPROVAL'; @@ -167,6 +181,30 @@ describe('AgentCoreRegistryClient', () => { expect(record.runtime).toEqual(runtime); }); + test('publish (native skill) round-trips a prompt_fragment containing an apostrophe', async () => { + const fake = new FakeClient(); + const client = makeClient(fake); + // The exact case that broke single-quoted YAML frontmatter (#246 review): + // js-yaml rejected `x-abca-runtime: '{"prompt_fragment":"Don't…"}'`. + const runtime = { prompt_fragment: "Don't skip tests; it's required.", tool_hints: ["Don't"] }; + const record = await client.publish({ + kind: 'skill', + namespace: 'acme', + name: 'strict-tester', + version: '1.0.0', + discovery: { description: 'Insists on tests' }, + runtime, + autoApprove: true, + }); + expect(record.runtime).toEqual(runtime); + // The stored frontmatter value must be base64 (no raw apostrophe/JSON), so + // the SKILL.md stays valid YAML for native descriptor validation. + const skillMd = (record.discovery as { skillMd: string }).skillMd; + const line = skillMd.split('\n').find((l) => l.startsWith('x-abca-runtime:'))!; + expect(line).not.toContain("'"); + expect(line).not.toContain('prompt_fragment'); // it's encoded, not raw JSON + }); + test('publish rejects a duplicate (kind,namespace,name,version)', async () => { const fake = new FakeClient(); const client = makeClient(fake); @@ -183,6 +221,115 @@ describe('AgentCoreRegistryClient', () => { await expect(client.publish(input)).rejects.toThrow(); }); + test('publish stamps + round-trips the publisher across MCP, skill, and CUSTOM', async () => { + const cases = [ + { kind: 'mcp_server', runtime: { transport: 'http' as const, url: 'https://x' }, discovery: { name: 'acme/a', description: 'd', version: '1.0.0' } }, + { kind: 'skill', runtime: { prompt_fragment: 'note' }, discovery: { description: 'd' } }, + { kind: 'cedar_policy_module', runtime: { cedar_text: 'permit(principal, action, resource);' }, discovery: { summary: 's' } }, + ]; + for (const c of cases) { + const client = makeClient(new FakeClient()); + const record = await client.publish({ + kind: c.kind, + namespace: 'acme', + name: 'thing', + version: '1.0.0', + discovery: c.discovery, + runtime: c.runtime as never, + publisher: 'cognito-sub-123', + autoApprove: true, + }); + expect(record.publisher).toBe('cognito-sub-123'); + } + }); + + test('publish without autoApprove still submits, landing in PENDING_APPROVAL (not DRAFT)', async () => { + const fake = new FakeClient(); + const client = makeClient(fake); + 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: { transport: 'http' as const, url: 'https://x' }, + // autoApprove omitted → must still reach PENDING_APPROVAL, never approve. + }); + expect(fake.sent).toEqual(['create', 'submit']); + expect(record.status).toBe('PENDING_APPROVAL'); + }); + + test('publish throws (not 201) when the record settles into CREATE_FAILED', async () => { + const fake = new FakeClient(); + fake.settleStatus = 'CREATE_FAILED'; + fake.settleReason = 'descriptor rejected by substrate'; + const client = makeClient(fake); + // The record was created before waitPastCreating saw CREATE_FAILED, so the + // failure surfaces as a RegistryPublishIncompleteError carrying the orphan's + // recordId; the underlying CREATE_FAILED reason rides on `cause`. + const err = 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: { transport: 'http' as const, url: 'https://x' }, + }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(RegistryPublishIncompleteError); + expect((err as RegistryPublishIncompleteError).recordId).toBeTruthy(); + expect(String((err as RegistryPublishIncompleteError).cause)).toMatch(/CREATE_FAILED.*descriptor rejected/); + // Never advanced past create — no submit/approve on a failed record. + expect(fake.sent).toEqual(['create']); + }); + + test('publish wraps a post-create submit failure in RegistryPublishIncompleteError', async () => { + const fake = new FakeClient(); + fake.failSubmit = true; + const client = makeClient(fake); + const err = 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: { transport: 'http' as const, url: 'https://x' }, + }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(RegistryPublishIncompleteError); + // The orphan's id is surfaced so an operator can find + delete/approve it. + expect((err as RegistryPublishIncompleteError).recordId).toBeTruthy(); + // Create + submit were attempted; submit failed before approve. + expect(fake.sent).toEqual(['create', 'submit']); + }); + + test('publish throws when the record never leaves CREATING (poll budget exhausted)', async () => { + jest.useFakeTimers(); + try { + const fake = new FakeClient(); + fake.settleStatus = 'CREATING'; // never settles + const client = makeClient(fake); + const p = 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: { transport: 'http' as const, url: 'https://x' }, + }); + // A timeout after create also strands a record, so it surfaces as + // RegistryPublishIncompleteError with the underlying reason on `cause`. + const assertion = expect(p).rejects.toBeInstanceOf(RegistryPublishIncompleteError); + const causeAssertion = p.catch((e: unknown) => { + expect(String((e as RegistryPublishIncompleteError).cause)).toMatch(/did not leave CREATING/); + }); + await jest.runAllTimersAsync(); + await assertion; + await causeAssertion; + expect(fake.sent).toEqual(['create']); + } finally { + jest.useRealTimers(); + } + }); + test('resolve picks the highest APPROVED version matching the constraint', async () => { const fake = new FakeClient(); const client = makeClient(fake); @@ -242,4 +389,41 @@ describe('AgentCoreRegistryClient', () => { }); await expect(client.resolve(parsed.ref)).rejects.toBeInstanceOf(RegistryResolutionError); }); + + test('resolve fails closed (REMOVED) when an APPROVED record has an empty runtime', async () => { + const fake = new FakeClient(); + const client = makeClient(fake); + // APPROVED, but the server.json carries no `_meta` runtime block at all — + // extractPayload would yield an empty runtime. Fail closed instead of + // resolving to {} (REGISTRY.md §8). + 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: 'APPROVED', + }); + 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: 'REMOVED' }); + await expect(client.resolve(parsed.ref)).rejects.toBeInstanceOf(RegistryResolutionError); + }); + + test('publish (native MCP) preserves a caller-supplied discovery._meta alongside ABCA keys', async () => { + const fake = new FakeClient(); + const client = makeClient(fake); + const runtime = { transport: 'http', url: 'https://x' }; + const record = await client.publish({ + kind: 'mcp_server', + namespace: 'acme', + name: 'pdf-tools', + version: '1.0.0', + discovery: { name: 'acme/pdf-tools', version: '1.0.0', _meta: { 'io.example.custom': { keep: true } } }, + runtime, + autoApprove: true, + }); + const meta = (record.discovery as Record)._meta as Record; + // ABCA runtime rides under its key AND the caller's own _meta key survives. + expect(meta).toMatchObject({ [RUNTIME_META_KEY]: runtime, 'io.example.custom': { keep: true } }); + }); }); diff --git a/cdk/test/handlers/shared/registry-resolution-ranking-parity.test.ts b/cdk/test/handlers/shared/registry-resolution-ranking-parity.test.ts new file mode 100644 index 000000000..74feb2a78 --- /dev/null +++ b/cdk/test/handlers/shared/registry-resolution-ranking-parity.test.ts @@ -0,0 +1,70 @@ +/** + * 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 RESOLUTION-ranking parity corpus runner (TypeScript side) (#246). + * + * Loads ``contracts/registry-resolution/resolution-cases.json`` and asserts + * ``selectHighest`` picks the golden winner for each (candidates, constraint). + * The companion runner ``agent/tests/test_registry_resolution_ranking_corpus.py`` + * runs the same file against the Python ``select_highest``. Ranking happens in + * BOTH languages (TS handler for the API, Python for the orchestrator's direct + * port), so a drift in caret/tilde/prerelease semantics would silently resolve + * different versions on the two paths — this corpus fails CI before that ships. + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { parseConstraint } from '../../../src/handlers/shared/registry/ref'; +import { selectHighest } from '../../../src/handlers/shared/registry/resolver'; + +const CASES_FILE = path.resolve( + __dirname, + '..', + '..', + '..', + '..', + 'contracts', + 'registry-resolution', + 'resolution-cases.json', +); + +interface Case { + name: string; + constraint: string; + candidates: string[]; + winner: string | null; +} + +const corpus = JSON.parse(fs.readFileSync(CASES_FILE, 'utf-8')) as { cases: Case[] }; + +describe('registry semver resolution-ranking parity corpus (TS selectHighest)', () => { + test('corpus is present and non-empty', () => { + expect(corpus.cases.length).toBeGreaterThan(0); + }); + + for (const c of corpus.cases) { + test(c.name, () => { + const constraint = parseConstraint(c.constraint); + expect(constraint).not.toBeNull(); + const winner = selectHighest(c.candidates, constraint!); + expect(winner).toBe(c.winner); + }); + } +}); diff --git a/cli/src/api-client.ts b/cli/src/api-client.ts index a82779c13..797cc78c1 100644 --- a/cli/src/api-client.ts +++ b/cli/src/api-client.ts @@ -46,7 +46,7 @@ import { RegistryPublishRequest, RegistryRecordResponse, RegistryResolveResponse, - RegistryVersionSummary, + RegistryShowResponse, SlackLinkResponse, PaginatedResponse, ReplayBundle, @@ -551,10 +551,8 @@ export class ApiClient { 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[] }> - >( + ): Promise { + const res = await this.request>( 'GET', `/registry/records/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`, ); diff --git a/cli/src/types.ts b/cli/src/types.ts index bf425924c..daf5976d2 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -68,6 +68,15 @@ export type RegistryVersionSummary = { readonly publisher: string | null; }; +/** `GET /registry/records/{kind}/{namespace}/{name}` response — one asset's + * versions. Mirrors cdk/src/handlers/shared/types.ts (types-sync contract). */ +export type RegistryShowResponse = { + readonly kind: string; + readonly namespace: string; + readonly name: string; + readonly versions: readonly RegistryVersionSummary[]; +}; + /** A record envelope returned by publish / show. */ export type RegistryRecordResponse = { readonly kind: string; diff --git a/contracts/registry-resolution/README.md b/contracts/registry-resolution/README.md index c8c1cb670..117c77d8c 100644 --- a/contracts/registry-resolution/README.md +++ b/contracts/registry-resolution/README.md @@ -1,13 +1,26 @@ # registry:// grammar parity fixtures (#246) -Golden `(ref) → verdict` vectors shared by the two `registry://` reference -parsers: +Golden vectors shared by the two `registry://` implementations. There are two +corpora, each run by a dual TS/Python runner: + +**1. Grammar** — `cases.json`, golden `(ref) → verdict`: - **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`. +**2. Resolution ranking** — `resolution-cases.json`, golden `(candidates, constraint) → winner`: + +- **Agent (Python):** [`agent/tests/test_registry_resolution_ranking_corpus.py`](../../agent/tests/test_registry_resolution_ranking_corpus.py) + runs each case through `registry.resolver.select_highest`. +- **CDK (TypeScript):** [`cdk/test/handlers/shared/registry-resolution-ranking-parity.test.ts`](../../cdk/test/handlers/shared/registry-resolution-ranking-parity.test.ts) + runs the same cases through `selectHighest`. + +Ranking runs in both languages (TS handler for the API, Python for the +orchestrator's direct port), so caret/tilde/prerelease semantics must stay in +lockstep — this second corpus is how we catch a ranking drift before deploy. + 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 diff --git a/contracts/registry-resolution/cases.json b/contracts/registry-resolution/cases.json index 8ae2abb61..cca4428fd 100644 --- a/contracts/registry-resolution/cases.json +++ b/contracts/registry-resolution/cases.json @@ -95,6 +95,11 @@ "name": "trailing-newline-rejected", "ref": "registry://mcp_server/acme/pdf-tools@1.0.0\n", "expected": { "ok": false, "reason": "INVALID_REGISTRY_REF" } + }, + { + "name": "component-beyond-max-safe-integer-rejected", + "ref": "registry://mcp_server/acme/pdf-tools@9007199254740993.0.0", + "expected": { "ok": false, "reason": "INVALID_CONSTRAINT" } } ] } diff --git a/contracts/registry-resolution/resolution-cases.json b/contracts/registry-resolution/resolution-cases.json new file mode 100644 index 000000000..ea294b7fa --- /dev/null +++ b/contracts/registry-resolution/resolution-cases.json @@ -0,0 +1,77 @@ +{ + "description": "Semver RESOLUTION parity corpus for the registry (#246). Each case is a set of candidate version strings plus a constraint; the expected `winner` is the highest candidate satisfying the constraint (or null for no match). Run against BOTH the TypeScript selectHighest (cdk/src/handlers/shared/registry/resolver.ts) and the Python select_highest (agent/src/registry/resolver.py). Both must agree. This complements cases.json (which covers grammar parsing only) by locking the RANKING semantics — caret/tilde ranges, prerelease exclusion, and near-MAX_SAFE_INTEGER comparisons — in lockstep across the two languages. See README.md.", + "cases": [ + { + "name": "exact-match", + "constraint": "1.4.1", + "candidates": ["1.4.0", "1.4.1", "1.4.2"], + "winner": "1.4.1" + }, + { + "name": "exact-no-match", + "constraint": "1.4.1", + "candidates": ["1.4.0", "1.4.2"], + "winner": null + }, + { + "name": "caret-picks-highest-within-major", + "constraint": "^1.4.1", + "candidates": ["1.4.1", "1.9.9", "1.9.10", "2.0.0"], + "winner": "1.9.10" + }, + { + "name": "caret-excludes-next-major", + "constraint": "^1.4.1", + "candidates": ["2.0.0", "2.5.0"], + "winner": null + }, + { + "name": "caret-zero-major-pins-minor", + "constraint": "^0.2.0", + "candidates": ["0.2.0", "0.2.9", "0.3.0"], + "winner": "0.2.9" + }, + { + "name": "tilde-pins-minor", + "constraint": "~1.4.1", + "candidates": ["1.4.1", "1.4.9", "1.5.0"], + "winner": "1.4.9" + }, + { + "name": "tilde-excludes-next-minor", + "constraint": "~1.4.1", + "candidates": ["1.5.0", "1.6.0"], + "winner": null + }, + { + "name": "prerelease-excluded-from-caret-range", + "constraint": "^1.4.1", + "candidates": ["1.4.1", "1.5.0-rc.1"], + "winner": "1.4.1" + }, + { + "name": "prerelease-ranks-below-its-release-on-exact", + "constraint": "1.4.1", + "candidates": ["1.4.1-rc.1", "1.4.1"], + "winner": "1.4.1" + }, + { + "name": "exact-prerelease-matches-only-itself", + "constraint": "1.4.1-rc.1", + "candidates": ["1.4.1-rc.1", "1.4.1"], + "winner": "1.4.1-rc.1" + }, + { + "name": "empty-candidate-set", + "constraint": "^1.0.0", + "candidates": [], + "winner": null + }, + { + "name": "near-max-safe-integer-patch-comparison", + "constraint": "^1.0.0", + "candidates": ["1.0.9007199254740991", "1.0.9007199254740990"], + "winner": "1.0.9007199254740991" + } + ] +} From 32755e617eb6b3ff2ec59c204fbaa3265887944a Mon Sep 17 00:00:00 2001 From: bgagent Date: Thu, 6 Aug 2026 14:08:20 -0400 Subject: [PATCH 4/5] fix(registry): move the registry API into its own nested stack to fit under the 500-resource cap (#246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orchestration arc (#695) grew the root AgentStack to ~467 resources; adding the registry surface pushed it to 506 (518 on the ECS compute path), over CloudFormation's hard 500-resource-per-stack limit — the stack no longer synthesized or deployed. API Gateway routes must live on the same stack as their RestApi, so the only way to move the registry surface off the root is to give it its own API: - New RegistryApi nested stack: own RestApi + Cognito authorizer (bound to the SHARED user pool, so a caller's JWT works on both APIs) + the four publish/resolve/list/show Lambdas + routes + access logging + the two Cognito groups. ~37 resources move off the root. - Root AgentStack now synthesizes at 469 (default) / 481 (ecs) — both under 500. - CLI: registry commands target a separate `registry_api_url` (from the new RegistryApiUrl stack output). `bgagent configure --stack-name` captures it automatically; `--registry-api-url` sets it manually. Optional in config for backward compatibility; `bgagent registry` errors clearly if unset. - REGISTRY.md §7 documents the separate-API rationale + the two-URL setup. - Also folds in the CLIENT_TOKEN_LENGTH lint fix for the provisioning handler. --- cdk/src/constructs/registry-api.ts | 212 ++++++++++++++++++ cdk/src/constructs/task-api.ts | 101 +-------- .../handlers/registry-provisioning/index.ts | 6 +- cdk/src/stacks/agent.ts | 15 ++ cdk/test/constructs/registry-api.test.ts | 85 +++++++ cdk/test/handlers/registry-handlers.test.ts | 23 +- cli/src/api-client.ts | 32 ++- cli/src/commands/admin.ts | 5 + cli/src/commands/configure.ts | 5 +- cli/src/commands/platform.ts | 1 + cli/src/stack-outputs.ts | 11 +- cli/src/types.ts | 5 + docs/design/REGISTRY.md | 4 +- .../src/content/docs/architecture/Registry.md | 4 +- 14 files changed, 397 insertions(+), 112 deletions(-) create mode 100644 cdk/src/constructs/registry-api.ts create mode 100644 cdk/test/constructs/registry-api.test.ts diff --git a/cdk/src/constructs/registry-api.ts b/cdk/src/constructs/registry-api.ts new file mode 100644 index 000000000..e4ef8048c --- /dev/null +++ b/cdk/src/constructs/registry-api.ts @@ -0,0 +1,212 @@ +/** + * 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 agent asset registry API (#246), isolated in its own NestedStack. +// +// WHY A SEPARATE API (not routes on the main TaskApi): the four handler Lambdas +// + their roles/policies + the API Gateway methods/resources/permissions are +// ~35 resources. Once the orchestration arc (#695) landed on the root +// AgentStack, the root was near CloudFormation's hard 500-resource-per-stack +// limit; adding the registry surface pushed it over (and further over on the +// ECS compute path). API Gateway routes must live on the same stack as their +// RestApi, so the only way to move the routes off the root is to give the +// registry its OWN RestApi. This nested stack owns the RestApi, a Cognito +// authorizer (bound to the SHARED user pool so credentials are identical), the +// four Lambdas, and their routes — reclaiming the whole surface from the root +// budget. The trade-off is a second invoke URL (`registryApiUrl`) the CLI must +// be configured with; see docs/design/REGISTRY.md. +import * as path from 'path'; +import { ArnFormat, Duration, NestedStack, type NestedStackProps, Stack } from 'aws-cdk-lib'; +import * as apigw from 'aws-cdk-lib/aws-apigateway'; +import * as cognito from 'aws-cdk-lib/aws-cognito'; +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 logs from 'aws-cdk-lib/aws-logs'; +import { NagSuppressions } from 'cdk-nag'; +import { Construct } from 'constructs'; + +/** Standard API-handler Lambda timeout (seconds); mirrors TaskApi's handlers. */ +const REGISTRY_HANDLER_TIMEOUT_SECONDS = 15; + +export interface RegistryApiProps extends NestedStackProps { + /** AgentCore registry id the handlers target (via `AGENT_REGISTRY_ID`). */ + readonly agentRegistryId: string; + /** The SHARED Cognito user pool — the registry API authorizes against the same + * pool as the main API, so a caller's existing JWT works unchanged. The two + * RegistryPublisher/RegistryApprover groups are created on this pool. */ + readonly userPool: cognito.IUserPool; + /** API Gateway stage name; matches the main API (default `v1`). */ + readonly stageName?: string; +} + +/** + * NestedStack exposing the registry REST API. {@link apiUrl} is the invoke URL + * the CLI targets for `registry` commands (distinct from the main API URL). + */ +export class RegistryApi extends NestedStack { + public readonly api: apigw.RestApi; + public readonly apiUrl: string; + + constructor(scope: Construct, id: string, props: RegistryApiProps) { + super(scope, id, props); + + // Two Cognito groups gate writes (REGISTRY.md §10): publishers submit, + // approvers drive records to APPROVED. Resolve/list/show are open to any + // authenticated caller. Created on the shared pool. + new cognito.CfnUserPoolGroup(this, 'RegistryPublisherGroup', { + userPoolId: props.userPool.userPoolId, + groupName: 'RegistryPublisher', + description: 'May publish agent asset registry records (#246).', + }); + new cognito.CfnUserPoolGroup(this, 'RegistryApproverGroup', { + userPoolId: props.userPool.userPoolId, + groupName: 'RegistryApprover', + description: 'May approve/reject/deprecate registry records and auto-approve on publish (#246).', + }); + + // --- Handler Lambdas --- + const handlersDir = path.join(__dirname, '..', 'handlers'); + const environment = { AGENT_REGISTRY_ID: props.agentRegistryId, ABCA_COMPONENT: 'registry-api' }; + // The AgentCore control-plane SDK is preview and NOT in the Lambda runtime, + // so bundle it (do not externalize) — mirrors the provisioning handler. + const bundling: lambda.BundlingOptions = { externalModules: [] }; + + const registryFn = (fnId: string, entry: string): lambda.NodejsFunction => + new lambda.NodejsFunction(this, fnId, { + // nosemgrep: javascript.lang.security.audit.path-traversal.path-join-resolve-traversal.path-join-resolve-traversal -- `entry` is always one of four hardcoded literals below (registry-{publish,resolve,list,show}.ts); never user input. handlersDir is a compile-time __dirname join. + entry: path.join(handlersDir, entry), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + environment, + bundling, + timeout: Duration.seconds(REGISTRY_HANDLER_TIMEOUT_SECONDS), + }); + + const publishFn = registryFn('RegistryPublishFn', 'registry-publish.ts'); + const resolveFn = registryFn('RegistryResolveFn', 'registry-resolve.ts'); + const listFn = registryFn('RegistryListFn', 'registry-list.ts'); + const showFn = registryFn('RegistryShowFn', 'registry-show.ts'); + + // Control-plane + data-plane actions, scoped to THIS registry (the id is + // known at synth time). Record ids are server-assigned, so the record ARN + // keeps a `/record/*` wildcard under this registry. + const registryArn = Stack.of(this).formatArn({ + service: 'bedrock-agentcore', + resource: 'registry', + resourceName: props.agentRegistryId, + arnFormat: ArnFormat.SLASH_RESOURCE_NAME, + }); + const recordArn = Stack.of(this).formatArn({ + service: 'bedrock-agentcore', + resource: 'registry', + resourceName: `${props.agentRegistryId}/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', + ]; + publishFn.addToRolePolicy( + new iam.PolicyStatement({ actions: [...readActions, ...writeActions], resources: [registryArn, recordArn] }), + ); + for (const fn of [resolveFn, listFn, showFn]) { + fn.addToRolePolicy(new iam.PolicyStatement({ actions: readActions, resources: [registryArn, recordArn] })); + } + + // --- REST API (own RestApi + Cognito authorizer on the shared pool) --- + // Access + method logging mirror the main TaskApi so cdk-nag APIG1/APIG6 pass. + const accessLogGroup = new logs.LogGroup(this, 'ApiAccessLogs', { + retention: logs.RetentionDays.ONE_MONTH, + }); + this.api = new apigw.RestApi(this, 'Api', { + restApiName: `${Stack.of(this).stackName}-registry`, + description: 'ABCA agent asset registry API (#246).', + deployOptions: { + stageName: props.stageName ?? 'v1', + accessLogDestination: new apigw.LogGroupLogDestination(accessLogGroup), + accessLogFormat: apigw.AccessLogFormat.jsonWithStandardFields(), + loggingLevel: apigw.MethodLoggingLevel.INFO, + }, + }); + + const authorizer = new apigw.CognitoUserPoolsAuthorizer(this, 'Authorizer', { + cognitoUserPools: [props.userPool], + }); + const authOptions: apigw.MethodOptions = { + authorizer, + authorizationType: apigw.AuthorizationType.COGNITO, + }; + + // --- Routes: /registry --- + const registry = this.api.root.addResource('registry'); + const records = registry.addResource('records'); + records.addMethod('POST', new apigw.LambdaIntegration(publishFn), authOptions); + records.addMethod('GET', new apigw.LambdaIntegration(listFn), authOptions); + + const resolve = registry.addResource('resolve'); + resolve.addMethod('GET', new apigw.LambdaIntegration(resolveFn), authOptions); + + // 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(showFn), authOptions); + + this.apiUrl = this.api.url; + + // --- cdk-nag suppressions --- + for (const fn of [publishFn, resolveFn, listFn, showFn]) { + NagSuppressions.addResourceSuppressions(fn, [ + { + id: 'AwsSolutions-IAM4', + reason: 'AWSLambdaBasicExecutionRole is required for CloudWatch Logs access', + }, + { + id: 'AwsSolutions-IAM5', + reason: 'bedrock-agentcore registry//record/* wildcard scoped to this registry because record ids are server-assigned and unknown at synth (#246)', + }, + ], true); + } + NagSuppressions.addResourceSuppressions(this.api, [ + { + id: 'AwsSolutions-APIG2', + reason: 'Request validation is performed in-handler (parseRef / body schema); registry payloads are small and typed at the handler boundary.', + }, + { + id: 'AwsSolutions-APIG3', + reason: 'No WAFv2 web ACL on the registry API — same posture as the internal/dev deployment; the API is Cognito-authenticated and operator-scoped (#246).', + }, + { + id: 'AwsSolutions-IAM4', + reason: 'AmazonAPIGatewayPushToCloudWatchLogs is the AWS-recommended managed policy for API Gateway CloudWatch logging', + }, + { + id: 'AwsSolutions-COG4', + reason: 'All routes use the Cognito authorizer bound to the shared user pool (authOptions).', + }, + ], true); + } +} diff --git a/cdk/src/constructs/task-api.ts b/cdk/src/constructs/task-api.ts index c862f5ad5..788b8f690 100644 --- a/cdk/src/constructs/task-api.ts +++ b/cdk/src/constructs/task-api.ts @@ -233,12 +233,6 @@ export interface TaskApiProps { */ 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; } /** @@ -1386,97 +1380,10 @@ 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 registry (not every - // registry in the account). The registry id is known at synth time - // (props.agentRegistryId), unlike create-time provisioning where it isn't — - // so there's no reason to widen to `registry/*`. Records get server-assigned - // ids, so the record ARN keeps a `/record/*` wildcard under this registry. - const registryArn = Stack.of(this).formatArn({ - service: 'bedrock-agentcore', - resource: 'registry', - resourceName: props.agentRegistryId, - arnFormat: ArnFormat.SLASH_RESOURCE_NAME, - }); - const recordArn = Stack.of(this).formatArn({ - service: 'bedrock-agentcore', - resource: 'registry', - resourceName: `${props.agentRegistryId}/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); - } + // Agent asset registry endpoints (#246) live in their own NestedStack with a + // separate RestApi (see RegistryApi + agent.ts) so their ~35 resources don't + // count against this root stack's 500-resource CloudFormation limit. Nothing + // for the registry API is created here. // --- cdk-nag suppressions for CDK-generated IAM policies --- for (const fn of allFunctions) { diff --git a/cdk/src/handlers/registry-provisioning/index.ts b/cdk/src/handlers/registry-provisioning/index.ts index 34b27629d..e6a0b13e9 100644 --- a/cdk/src/handlers/registry-provisioning/index.ts +++ b/cdk/src/handlers/registry-provisioning/index.ts @@ -64,6 +64,10 @@ interface IsCompleteResponse { const client = new BedrockAgentCoreControlClient({}); +/** clientToken length cap — a 64-hex-char (256-bit) prefix of the SHA-256 digest + * is plenty of entropy for an idempotency token and stays within API limits. */ +const CLIENT_TOKEN_LENGTH = 64; + /** 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; @@ -74,7 +78,7 @@ function registryIdFromArn(arn: string): string { * an at-least-once retry of the same logical create is a substrate no-op rather * than a duplicate registry. */ function createTokenFrom(requestId: string | undefined, registryName: string): string { - return createHash('sha256').update(`${requestId ?? ''}:${registryName}`).digest('hex').slice(0, 64); + return createHash('sha256').update(`${requestId ?? ''}:${registryName}`).digest('hex').slice(0, CLIENT_TOKEN_LENGTH); } export async function onEvent(event: OnEventRequest): Promise { diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index ab3da97ad..a4edd6473 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -58,6 +58,7 @@ import { OrchestrationReconciler } from '../constructs/orchestration-reconciler' import { OrchestrationTable } from '../constructs/orchestration-table'; import { PendingUploadCleanup } from '../constructs/pending-upload-cleanup'; import { AgentRegistryStack } from '../constructs/registry'; +import { RegistryApi } from '../constructs/registry-api'; import { RepoTable } from '../constructs/repo-table'; import { SlackIntegration } from '../constructs/slack-integration'; import { buildAppId } from '../constructs/solution-ua-aspect'; @@ -393,7 +394,16 @@ export class AgentStack extends Stack { // immediately. Omitted when no image is configured — there can be no // MicroVM-backed task to cancel then. ...(microvmImageConfigured && { lambdaMicrovmImageArn: lazyMicrovmImageArn }), + }); + + // Agent asset registry API (#246) in its own NestedStack + RestApi so its + // ~35 resources don't count against this root stack's 500-resource limit. + // It authorizes against the SHARED Cognito user pool, so a caller's JWT works + // on both APIs; the CLI targets its distinct URL (RegistryApiUrl output) for + // `registry` commands. + const registryApi = new RegistryApi(this, 'RegistryApi', { agentRegistryId: agentRegistry.registryId, + userPool: taskApi.userPool, }); // --- Tool-federation Gateway (ADR-019 P1, CONTEXT-GATED) --- @@ -1718,6 +1728,11 @@ export class AgentStack extends Stack { description: 'URL of the Task API', }); + new CfnOutput(this, 'RegistryApiUrl', { + value: registryApi.apiUrl, + description: 'URL of the agent asset registry API (#246) — the CLI targets this for `bgagent registry` commands', + }); + new CfnOutput(this, 'UserPoolId', { value: taskApi.userPool.userPoolId, description: 'Cognito User Pool ID', diff --git a/cdk/test/constructs/registry-api.test.ts b/cdk/test/constructs/registry-api.test.ts new file mode 100644 index 000000000..4bd38ea6e --- /dev/null +++ b/cdk/test/constructs/registry-api.test.ts @@ -0,0 +1,85 @@ +/** + * 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 * as cognito from 'aws-cdk-lib/aws-cognito'; +import { RegistryApi } from '../../src/constructs/registry-api'; + +function synth(): { template: Template; registryApi: RegistryApi } { + const app = new App(); + const parent = new Stack(app, 'ParentStack'); + const userPool = new cognito.UserPool(parent, 'UserPool'); + const registryApi = new RegistryApi(parent, 'RegistryApi', { + agentRegistryId: 'reg-abc123', + userPool, + }); + // A NestedStack renders as AWS::CloudFormation::Stack in the parent; assert on + // the nested stack's own synthesized template. + return { template: Template.fromStack(registryApi), registryApi }; +} + +describe('RegistryApi nested stack', () => { + test('creates the four handler Lambdas', () => { + const { template } = synth(); + template.resourceCountIs('AWS::Lambda::Function', 4); + }); + + test('creates its own REST API + Cognito authorizer (not the shared API)', () => { + const { template } = synth(); + template.resourceCountIs('AWS::ApiGateway::RestApi', 1); + template.resourceCountIs('AWS::ApiGateway::Authorizer', 1); + template.hasResourceProperties('AWS::ApiGateway::Authorizer', { + Type: 'COGNITO_USER_POOLS', + }); + }); + + test('creates the two Cognito groups on the shared pool', () => { + const { template } = synth(); + template.resourceCountIs('AWS::Cognito::UserPoolGroup', 2); + template.hasResourceProperties('AWS::Cognito::UserPoolGroup', { + GroupName: 'RegistryPublisher', + }); + template.hasResourceProperties('AWS::Cognito::UserPoolGroup', { + GroupName: 'RegistryApprover', + }); + }); + + test('publish role gets write actions scoped to the wired registry', () => { + const { template } = synth(); + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Action: Match.arrayWith([ + 'bedrock-agentcore:CreateRegistryRecord', + 'bedrock-agentcore:SubmitRegistryRecordForApproval', + 'bedrock-agentcore:UpdateRegistryRecordStatus', + ]), + }), + ]), + }, + }); + }); + + test('exposes an apiUrl for the CLI to target', () => { + const { registryApi } = synth(); + expect(registryApi.apiUrl).toBeDefined(); + }); +}); diff --git a/cdk/test/handlers/registry-handlers.test.ts b/cdk/test/handlers/registry-handlers.test.ts index 1f9bd23d2..d4d58b439 100644 --- a/cdk/test/handlers/registry-handlers.test.ts +++ b/cdk/test/handlers/registry-handlers.test.ts @@ -26,9 +26,12 @@ 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). +// Mock the factory so handlers get our fake client (no AWS). `publish` is a +// standalone fn (not just a method on the object) so tests can assert on it +// without tripping @typescript-eslint/unbound-method. +const mockPublish = jest.fn(); const mockClient: jest.Mocked = { - publish: jest.fn(), + publish: mockPublish, getRecord: jest.fn(), listRecords: jest.fn(), resolve: jest.fn(), @@ -164,7 +167,7 @@ describe('registry-publish handler', () => { test('rejects an array runtime', async () => { expect(await publishAs({ ...validPublishBody, runtime: [] })).toBe(400); - expect(mockClient.publish).not.toHaveBeenCalled(); + expect(mockPublish).not.toHaveBeenCalled(); }); test('rejects an empty mcp_server runtime', async () => { @@ -193,8 +196,14 @@ describe('registry-publish handler', () => { test('accepts a well-formed stdio mcp_server (command, no url)', 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, + kind: 'mcp_server', + namespace: 'acme', + name: 'pdf-tools', + version: '1.0.0', + status: 'PENDING_APPROVAL', + storageMode: 'native', + discovery: {}, + runtime: {} as never, }); expect(await publishAs({ ...validPublishBody, runtime: { transport: 'stdio', command: 'run-me' } })).toBe(201); }); @@ -245,7 +254,7 @@ describe('registry-resolve handler', () => { runtime: { transport: 'http', url: 'https://x', - headers: { Authorization: 'Bearer registry-secret', 'X-Api-Key': 'topsecret' }, + headers: { 'Authorization': 'Bearer registry-secret', 'X-Api-Key': 'topsecret' }, } as never, warnings: [], }); @@ -253,7 +262,7 @@ describe('registry-resolve handler', () => { expect(res.statusCode).toBe(200); const { runtime } = JSON.parse(res.body).data; // Header keys survive (discovery signal); values are masked. - expect(runtime.headers).toEqual({ Authorization: '***', 'X-Api-Key': '***' }); + expect(runtime.headers).toEqual({ 'Authorization': '***', 'X-Api-Key': '***' }); expect(JSON.stringify(res.body)).not.toContain('registry-secret'); expect(JSON.stringify(res.body)).not.toContain('topsecret'); // Non-secret fields are untouched. diff --git a/cli/src/api-client.ts b/cli/src/api-client.ts index 797cc78c1..6bfde513b 100644 --- a/cli/src/api-client.ts +++ b/cli/src/api-client.ts @@ -73,6 +73,8 @@ export interface ApiClientOptions { export class ApiClient { private baseUrl: string | undefined; + private registryBaseUrl: string | undefined; + private readonly apiKey: string | undefined; constructor(options: ApiClientOptions = {}) { @@ -88,18 +90,36 @@ export class ApiClient { return this.baseUrl; } + /** Base URL for the agent asset registry API (#246). It is a SEPARATE API + * Gateway from the main one (RegistryApiUrl stack output), so it has its own + * config field. Throws a clear error if the config predates the registry. */ + private getRegistryBaseUrl(): string { + if (!this.registryBaseUrl) { + const config = loadConfig(); + if (!config.registry_api_url) { + throw new Error( + 'registry_api_url is not set in your bgagent config. Re-run setup (or add ' + + 'registry_api_url from the stack\'s RegistryApiUrl output) to use `bgagent registry`.', + ); + } + this.registryBaseUrl = config.registry_api_url.replace(/\/+$/, ''); + } + return this.registryBaseUrl; + } + private async request( method: string, path: string, body?: unknown, headers?: Record, signal?: AbortSignal, + baseUrl?: string, ): Promise { // API-key mode skips Cognito entirely: no cached token, no refresh. const authHeaders: Record = this.apiKey ? { 'X-API-Key': this.apiKey } : { Authorization: await getAuthToken() }; - const url = `${this.getBaseUrl()}${path}`; + const url = `${baseUrl ?? this.getBaseUrl()}${path}`; debug(`${method} ${url}`); // Redaction + stringification are gated on isVerbose() so the deep copy @@ -518,9 +538,14 @@ export class ApiClient { // --- Agent asset registry (#246) --- + // Registry (#246) commands target the SEPARATE registry API (its own API + // Gateway); getRegistryBaseUrl() resolves registry_api_url from config. + /** POST /registry/records — publish an asset record. */ async registryPublish(req: RegistryPublishRequest): Promise { - const res = await this.request>('POST', '/registry/records', req); + const res = await this.request>( + 'POST', '/registry/records', req, undefined, undefined, this.getRegistryBaseUrl(), + ); return res.data; } @@ -529,6 +554,7 @@ export class ApiClient { const res = await this.request>( 'GET', `/registry/resolve?ref=${encodeURIComponent(ref)}`, + undefined, undefined, undefined, this.getRegistryBaseUrl(), ); return res.data; } @@ -542,6 +568,7 @@ export class ApiClient { const res = await this.request>( 'GET', `/registry/records${qs ? `?${qs}` : ''}`, + undefined, undefined, undefined, this.getRegistryBaseUrl(), ); return res.data.assets; } @@ -555,6 +582,7 @@ export class ApiClient { const res = await this.request>( 'GET', `/registry/records/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`, + undefined, undefined, undefined, this.getRegistryBaseUrl(), ); return res.data; } diff --git a/cli/src/commands/admin.ts b/cli/src/commands/admin.ts index fb93c0917..c62993e5a 100644 --- a/cli/src/commands/admin.ts +++ b/cli/src/commands/admin.ts @@ -64,6 +64,8 @@ export function generateTempPassword(): string { export function encodeBundle(config: CliConfig): string { const json = JSON.stringify({ api_url: config.api_url, + // Optional (#246): only present once the registry is deployed. + ...(config.registry_api_url ? { registry_api_url: config.registry_api_url } : {}), region: config.region, user_pool_id: config.user_pool_id, client_id: config.client_id, @@ -100,6 +102,9 @@ export function decodeBundle(bundle: string): CliConfig { } return { api_url: obj.api_url as string, + ...(typeof obj.registry_api_url === 'string' && obj.registry_api_url.length > 0 + ? { registry_api_url: obj.registry_api_url } + : {}), region: obj.region as string, user_pool_id: obj.user_pool_id as string, client_id: obj.client_id as string, diff --git a/cli/src/commands/configure.ts b/cli/src/commands/configure.ts index af659da99..4f2ac3c4d 100644 --- a/cli/src/commands/configure.ts +++ b/cli/src/commands/configure.ts @@ -35,6 +35,7 @@ export function makeConfigureCommand(): Command { return new Command('configure') .description('Configure the CLI with API endpoint and Cognito settings') .option('--api-url ', 'API Gateway base URL') + .option('--registry-api-url ', 'Agent asset registry API base URL (#246; separate API Gateway)') .option('--region ', 'AWS region') .option('--user-pool-id ', 'Cognito User Pool ID') .option('--client-id ', 'Cognito App Client ID') @@ -44,7 +45,7 @@ export function makeConfigureCommand(): Command { 'Read ApiUrl, UserPoolId, and AppClientId from CloudFormation stack outputs', ) .action(async (opts) => { - const individualFlagsProvided = opts.apiUrl || opts.region || opts.userPoolId || opts.clientId; + const individualFlagsProvided = opts.apiUrl || opts.registryApiUrl || opts.region || opts.userPoolId || opts.clientId; if (opts.fromBundle && (individualFlagsProvided || opts.stackName)) { throw new CliError( '--from-bundle is mutually exclusive with --api-url / --region / --user-pool-id / --client-id / --stack-name.', @@ -67,12 +68,14 @@ export function makeConfigureCommand(): Command { ...providedFields, ...(opts.region !== undefined ? { region: opts.region } : {}), ...(opts.apiUrl !== undefined ? { api_url: opts.apiUrl } : {}), + ...(opts.registryApiUrl !== undefined ? { registry_api_url: opts.registryApiUrl } : {}), ...(opts.userPoolId !== undefined ? { user_pool_id: opts.userPoolId } : {}), ...(opts.clientId !== undefined ? { client_id: opts.clientId } : {}), }; } else { providedFields = { ...(opts.apiUrl !== undefined ? { api_url: opts.apiUrl } : {}), + ...(opts.registryApiUrl !== undefined ? { registry_api_url: opts.registryApiUrl } : {}), ...(opts.region !== undefined ? { region: opts.region } : {}), ...(opts.userPoolId !== undefined ? { user_pool_id: opts.userPoolId } : {}), ...(opts.clientId !== undefined ? { client_id: opts.clientId } : {}), diff --git a/cli/src/commands/platform.ts b/cli/src/commands/platform.ts index eecd4e225..0b83a02b1 100644 --- a/cli/src/commands/platform.ts +++ b/cli/src/commands/platform.ts @@ -27,6 +27,7 @@ import { listStackOutputs } from '../stack-outputs'; /** Stack outputs most operators need during setup (shown first in text mode). */ const HIGHLIGHT_OUTPUT_KEYS = [ 'ApiUrl', + 'RegistryApiUrl', 'UserPoolId', 'AppClientId', 'GitHubTokenSecretArn', diff --git a/cli/src/stack-outputs.ts b/cli/src/stack-outputs.ts index 699cf1ae5..4cd7ad98e 100644 --- a/cli/src/stack-outputs.ts +++ b/cli/src/stack-outputs.ts @@ -71,7 +71,10 @@ export async function listStackOutputs(region: string, stackName: string): Promi } /** CloudFormation output keys written by `bgagent configure`. */ -export const CONFIGURE_STACK_OUTPUT_KEYS = ['ApiUrl', 'UserPoolId', 'AppClientId'] as const; +/** Stack outputs read into the CLI config. `RegistryApiUrl` is optional (only + * present once the registry #246 is deployed); the rest are required. */ +export const CONFIGURE_STACK_OUTPUT_KEYS = ['ApiUrl', 'RegistryApiUrl', 'UserPoolId', 'AppClientId'] as const; +const REQUIRED_CONFIGURE_OUTPUT_KEYS = ['ApiUrl', 'UserPoolId', 'AppClientId'] as const; /** * Resolve configure fields from stack outputs. @@ -84,6 +87,7 @@ export async function resolveConfigureBundleFromStack( const outputs = await listStackOutputs(region, stackName); const byKey = new Map(outputs.map((o) => [o.key, o.value])); const apiUrl = byKey.get('ApiUrl'); + const registryApiUrl = byKey.get('RegistryApiUrl'); const userPoolId = byKey.get('UserPoolId'); const appClientId = byKey.get('AppClientId'); if (!apiUrl || !userPoolId || !appClientId) { @@ -91,6 +95,9 @@ export async function resolveConfigureBundleFromStack( } return { api_url: apiUrl, + // Optional: only present once the registry (#246) is deployed. `bgagent + // registry` commands require it; other commands don't. + ...(registryApiUrl ? { registry_api_url: registryApiUrl } : {}), region, user_pool_id: userPoolId, client_id: appClientId, @@ -108,7 +115,7 @@ export async function fetchConfigureBundleFromStack( } const outputs = await listStackOutputs(region, stackName); const byKey = new Map(outputs.map((o) => [o.key, o.value])); - const missing = CONFIGURE_STACK_OUTPUT_KEYS.filter((key) => !byKey.get(key)); + const missing = REQUIRED_CONFIGURE_OUTPUT_KEYS.filter((key) => !byKey.get(key)); throw new CliError( `Stack '${stackName}' is missing configure outputs in ${region}: ${missing.join(', ')}. ` + 'Deploy the stack or pass --api-url / --user-pool-id / --client-id explicitly.', diff --git a/cli/src/types.ts b/cli/src/types.ts index daf5976d2..32bc4bbd8 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -594,6 +594,11 @@ export interface JiraLinkResponse { /** CLI config stored in ~/.bgagent/config.json. */ export interface CliConfig { readonly api_url: string; + /** URL of the agent asset registry API (#246), which is a separate API + * Gateway from `api_url` (see RegistryApiUrl stack output). Optional for + * backward compatibility with configs written before the registry shipped; + * `bgagent registry` commands require it. */ + readonly registry_api_url?: string; readonly region: string; readonly user_pool_id: string; readonly client_id: string; diff --git a/docs/design/REGISTRY.md b/docs/design/REGISTRY.md index 9f22b88b4..9e0ed6824 100644 --- a/docs/design/REGISTRY.md +++ b/docs/design/REGISTRY.md @@ -85,7 +85,9 @@ CreateRegistryRecord → poll until not CREATING ## 7. API contract -All routes are under the existing API Gateway stage (`/v1`), Cognito-authenticated. Wire fields are snake_case. +The registry API is a **separate API Gateway** from the main Task API (its own `RestApi`, exposed as the `RegistryApiUrl` stack output), but authorized against the **same** Cognito user pool — so a caller's existing JWT works on both without re-auth. All routes are under the `/v1` stage, Cognito-authenticated. Wire fields are snake_case. + +> **Why a separate API.** The registry API's handler Lambdas + routes are ~35 CloudFormation resources. Once the orchestration arc (#695) landed, the root `AgentStack` was near CloudFormation's hard 500-resource-per-stack limit; API Gateway routes must live on the same stack as their `RestApi`, so giving the registry its own API (in a nested stack) is the only way to move that surface off the root and keep both the default and ECS compute paths under the cap. The cost is one extra config value: the CLI reads `registry_api_url` (from the `RegistryApiUrl` output) for `bgagent registry` commands — `bgagent configure --stack-name …` captures it automatically, or pass `--registry-api-url` explicitly. ### 7.1 `POST /registry/records` — publish diff --git a/docs/src/content/docs/architecture/Registry.md b/docs/src/content/docs/architecture/Registry.md index 4989172e0..849c7c52c 100644 --- a/docs/src/content/docs/architecture/Registry.md +++ b/docs/src/content/docs/architecture/Registry.md @@ -89,7 +89,9 @@ CreateRegistryRecord → poll until not CREATING ## 7. API contract -All routes are under the existing API Gateway stage (`/v1`), Cognito-authenticated. Wire fields are snake_case. +The registry API is a **separate API Gateway** from the main Task API (its own `RestApi`, exposed as the `RegistryApiUrl` stack output), but authorized against the **same** Cognito user pool — so a caller's existing JWT works on both without re-auth. All routes are under the `/v1` stage, Cognito-authenticated. Wire fields are snake_case. + +> **Why a separate API.** The registry API's handler Lambdas + routes are ~35 CloudFormation resources. Once the orchestration arc (#695) landed, the root `AgentStack` was near CloudFormation's hard 500-resource-per-stack limit; API Gateway routes must live on the same stack as their `RestApi`, so giving the registry its own API (in a nested stack) is the only way to move that surface off the root and keep both the default and ECS compute paths under the cap. The cost is one extra config value: the CLI reads `registry_api_url` (from the `RegistryApiUrl` output) for `bgagent registry` commands — `bgagent configure --stack-name …` captures it automatically, or pass `--registry-api-url` explicitly. ### 7.1 `POST /registry/records` — publish From e3b2caef0d1a28c81152d616bc526356d58c7063 Mon Sep 17 00:00:00 2001 From: bgagent Date: Tue, 11 Aug 2026 20:50:15 -0400 Subject: [PATCH 5/5] fix(registry): close read-path + descriptor-integrity security findings (#246) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review pass (@scottschreckengaust) on the catalog PR: - B1: SKILL.md frontmatter is now emitted/parsed via a real YAML serializer (js-yaml / pyyaml) instead of line concatenation + first-match regex, so a caller-controlled `description` can no longer inject a shadowing `x-abca-runtime` key and bypass publish-time validation. TS + Python parity, legacy single-quoted form still read; injection regression tests both sides. - B2: resolve-response redaction switches from a 3-key denylist to a per-kind allowlist (drops api_key/env/etc.; url reduced to origin), and publish now rejects unknown runtime keys — closing the open payload at both boundaries. - B3: registry SDK clients route through makeClient for solution UA (#319); ABCA_COMPONENT set on the provisioning Lambdas. - nits: reject non-boolean custom/auto_approve flags; registry-api IAM test now asserts scoped registry ARNs (no bare "*"). --- agent/src/registry/agentcore_client.py | 29 +++++-- agent/tests/test_registry_agentcore_client.py | 22 +++++ cdk/src/constructs/registry.ts | 3 + .../handlers/registry-provisioning/index.ts | 5 +- cdk/src/handlers/registry-publish.ts | 26 ++++++ cdk/src/handlers/registry-resolve.ts | 73 ++++++++++------- .../shared/registry/agentcore-client.ts | 80 ++++++++++++------- cdk/test/constructs/registry-api.test.ts | 21 +++++ cdk/test/handlers/registry-handlers.test.ts | 48 ++++++++--- .../handlers/shared/agentcore-client.test.ts | 21 ++++- 10 files changed, 251 insertions(+), 77 deletions(-) diff --git a/agent/src/registry/agentcore_client.py b/agent/src/registry/agentcore_client.py index 47523e224..c5499fa3a 100644 --- a/agent/src/registry/agentcore_client.py +++ b/agent/src/registry/agentcore_client.py @@ -13,6 +13,8 @@ import re from typing import TYPE_CHECKING, Any +import yaml + from registry.client import RegistryResolutionError, ResolvedAsset from registry.resolver import select_highest @@ -28,9 +30,11 @@ # 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" -# Capture the whole frontmatter value; the runtime is base64-encoded JSON (new -# form) or, for records published before the base64 switch, single-quoted JSON. -_SKILL_RUNTIME_RE = re.compile(rf"^{_SKILL_RUNTIME_FM_KEY}:\s*(.+?)\s*$", re.MULTILINE) +# Match the whole frontmatter block between the first ---/--- pair. We parse that +# block as one YAML document (not a per-line regex) so a caller-controlled +# `description` containing a newline cannot inject a shadowing runtime key +# (#246 review B1/B2) — mirrors parseSkillFrontmatter in agentcore-client.ts. +_SKILL_FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---", re.DOTALL) _RESOLVABLE_STATUSES = ("APPROVED", "DEPRECATED") @@ -71,13 +75,22 @@ def _extract_runtime(self, raw: dict[str, Any]) -> dict[str, Any]: skill_md = ( descriptors.get("agentSkills", {}).get("skillMd", {}).get("inlineContent", "") ) - m = _SKILL_RUNTIME_RE.search(skill_md) + m = _SKILL_FRONTMATTER_RE.search(skill_md) if not m: return {} - raw_value = m.group(1) - # Legacy form: '' (single-quoted). New form: bare base64. - if raw_value.startswith("'") and raw_value.endswith("'"): - return json.loads(raw_value[1:-1]) + try: + fm = yaml.safe_load(m.group(1)) + except yaml.YAMLError: + return {} + if not isinstance(fm, dict): + return {} + raw_value = fm.get(_SKILL_RUNTIME_FM_KEY) + if not isinstance(raw_value, str): + return {} + # Legacy form: raw JSON (YAML already unwrapped its single-quoting, so + # the value starts with `{`). New form: base64-encoded JSON. + if raw_value.lstrip().startswith("{"): + return json.loads(raw_value) return json.loads(base64.b64decode(raw_value).decode("utf-8")) # MCP: JSON server.json with the runtime in a `_meta` block. inline = descriptors.get("mcp", {}).get("server", {}).get("inlineContent") or "{}" diff --git a/agent/tests/test_registry_agentcore_client.py b/agent/tests/test_registry_agentcore_client.py index 1af87a702..087664151 100644 --- a/agent/tests/test_registry_agentcore_client.py +++ b/agent/tests/test_registry_agentcore_client.py @@ -104,6 +104,28 @@ def test_agent_skills_missing_frontmatter_key_returns_empty(self): } assert _client()._extract_runtime(raw) == {} + def test_agent_skills_newline_description_cannot_inject_runtime_key(self): + # B1 (#246): a description carrying a newline + a second x-abca-runtime + # line must not shadow the validated runtime. Frontmatter emitted by the + # safe builder quotes the description, so the injection stays a value. + import yaml + + legit = {"prompt_fragment": "THE VALIDATED FRAGMENT"} + injected = json.dumps({"prompt_fragment": "INJECTED"}).encode() + injected_b64 = base64.b64encode(injected).decode() + frontmatter = { + "name": "acme-tdd", + "description": f"benign\nx-abca-runtime: {injected_b64}", + "version": "1.0.0", + "x-abca-runtime": base64.b64encode(json.dumps(legit).encode()).decode(), + } + skill_md = "---\n" + yaml.dump(frontmatter).strip() + "\n---\n# acme/tdd\nbody" + raw = { + "descriptorType": "AGENT_SKILLS", + "descriptors": {"agentSkills": {"skillMd": {"inlineContent": skill_md}}}, + } + assert _client()._extract_runtime(raw) == legit + class TestResolveFailClosed: """resolve() must never hand back a record with an empty/unreadable runtime — diff --git a/cdk/src/constructs/registry.ts b/cdk/src/constructs/registry.ts index 195343cde..341a97ed9 100644 --- a/cdk/src/constructs/registry.ts +++ b/cdk/src/constructs/registry.ts @@ -73,6 +73,9 @@ export class AgentRegistry extends Construct { timeout: Duration.seconds(PROVISION_TIMEOUT_SECONDS), memorySize: PROVISION_MEMORY_MB, bundling, + // Names this component in the solution UA segment (#319) instead of + // falling through to the generic `api` default. + environment: { ABCA_COMPONENT: 'registry-provisioning' }, }; const onEventFn = new lambda.NodejsFunction(this, 'OnEventFn', { diff --git a/cdk/src/handlers/registry-provisioning/index.ts b/cdk/src/handlers/registry-provisioning/index.ts index e6a0b13e9..c6eaf59cb 100644 --- a/cdk/src/handlers/registry-provisioning/index.ts +++ b/cdk/src/handlers/registry-provisioning/index.ts @@ -37,6 +37,7 @@ import { ResourceNotFoundException, } from '@aws-sdk/client-bedrock-agentcore-control'; import { logger } from '../shared/logger'; +import { makeClient } from '../shared/ua'; // The Provider framework's request/response shapes are not exported from // aws-cdk-lib's public entrypoints, so we model the fields we use. @@ -62,7 +63,9 @@ interface IsCompleteResponse { readonly Data?: Record; } -const client = new BedrockAgentCoreControlClient({}); +// Route through makeClient so the ABCA solution UA segment is attached (#319); +// a naked `new BedrockAgentCoreControlClient({})` silently drops attribution. +const client = makeClient(BedrockAgentCoreControlClient); /** clientToken length cap — a 64-hex-char (256-bit) prefix of the SHA-256 digest * is plenty of entropy for an idempotency token and stays within API limits. */ diff --git a/cdk/src/handlers/registry-publish.ts b/cdk/src/handlers/registry-publish.ts index 703c806f3..e9a57605e 100644 --- a/cdk/src/handlers/registry-publish.ts +++ b/cdk/src/handlers/registry-publish.ts @@ -141,6 +141,14 @@ function validate(body: RegistryPublishRequest): string | null { if (!isPlainObject(body.runtime)) { return 'runtime must be a JSON object.'; } + // The flags are typed boolean? — enforce it so a truthy string like + // `custom: "false"` can't silently flip storage mode (#246 review nit). + if (body.custom !== undefined && typeof body.custom !== 'boolean') { + return 'custom, when present, must be a boolean.'; + } + if (body.auto_approve !== undefined && typeof body.auto_approve !== 'boolean') { + return 'auto_approve, when present, must be a boolean.'; + } return validateRuntime(body.kind, body.runtime); } @@ -157,7 +165,25 @@ function isPlainObject(v: unknown): v is Record { * skipped it — while the task audit still claimed the pin was used. Reject those * at publish so a published record's runtime is always loadable. */ +/** Keys each kind's runtime may carry. Publishing any other key is rejected so + * the payload is closed at the gateway — a publisher cannot smuggle an + * `api_key`/`env`/… field that a denylist-based reader would later leak + * (#246 review B2). Kept in sync with the allowlist in registry-resolve.ts. */ +const ALLOWED_RUNTIME_KEYS: Record> = { + mcp_server: new Set(['transport', 'url', 'command', 'args', 'headers', 'tool_prefix']), + cedar_policy_module: new Set(['cedar_text']), + skill: new Set(['prompt_fragment', 'tool_hints']), +}; + function validateRuntime(kind: string, runtime: Record): string | null { + const allowed = ALLOWED_RUNTIME_KEYS[kind]; + if (allowed) { + const unknown = Object.keys(runtime).filter((k) => !allowed.has(k)); + if (unknown.length > 0) { + return `${kind} runtime has unsupported field(s): ${unknown.join(', ')}. ` + + `Allowed: ${[...allowed].join(', ')}. Credentials must be referenced (e.g. a Secrets Manager ARN), not inlined.`; + } + } switch (kind) { case 'mcp_server': { const transport = runtime.transport; diff --git a/cdk/src/handlers/registry-resolve.ts b/cdk/src/handlers/registry-resolve.ts index 624ee0ca0..69060ed9d 100644 --- a/cdk/src/handlers/registry-resolve.ts +++ b/cdk/src/handlers/registry-resolve.ts @@ -28,35 +28,54 @@ import { ErrorCode, errorResponse, successResponse } from './shared/response'; import type { RegistryResolveResponse } from './shared/types'; /** - * Redact secret-bearing fields from a runtime payload before returning it on the - * human-facing resolve response. An mcp_server payload may carry secrets in two - * places: `headers` (e.g. `Authorization: Bearer …`) on http/sse transports, and - * `command`/`args` on a `stdio` transport (tokens are routinely passed as CLI - * args such as `--api-key=…` or embedded in the command string). This endpoint is - * open to any authenticated caller (resolve/read is not group-gated, - * REGISTRY.md §10), so returning either verbatim would turn the catalog into a - * tenant-wide secret-read endpoint (#246 review). Header *keys* are retained as - * discovery signal — a caller can see the server expects an `Authorization` - * header without learning its value; `command`/`args` are masked wholesale - * because their structure itself can encode secrets. The orchestrator does NOT go - * through this handler: it resolves via the `RegistryClient` port directly and - * receives the unredacted payload it needs to connect. + * Project a runtime payload down to its known-safe discovery fields before + * returning it on the human-facing resolve response. This endpoint is open to + * any authenticated caller (resolve/read is not group-gated, REGISTRY.md §10), + * and the runtime is an open `Record` — a publisher can attach + * arbitrary keys (`api_key`, `env`, a token in a `url` query string, …). A + * denylist over a few field names is therefore fail-open by construction, so we + * fail closed with an **allowlist**: only fields that are structurally + * non-secret for the given kind are returned, everything else is dropped + * (#246 review B1/B2). The orchestrator does NOT go through this handler — it + * resolves via the `RegistryClient` port directly and receives the full, + * unredacted payload it needs to connect. */ -function redactRuntimeForResponse(runtime: Record): Record { - const out = { ...runtime }; - const headers = out.headers; - if (headers && typeof headers === 'object' && !Array.isArray(headers)) { - const redacted: Record = {}; - for (const key of Object.keys(headers as Record)) { - redacted[key] = '***'; - } - out.headers = redacted; +function redactRuntimeForResponse( + kind: string, + runtime: Record, +): Record { + if (kind === 'cedar_policy_module') { + // Cedar policy source is not a secret (it is authored policy text). + return typeof runtime.cedar_text === 'string' ? { cedar_text: runtime.cedar_text } : {}; } - if (typeof out.command === 'string') { - out.command = '***'; + if (kind === 'skill') { + // Skills are prompt text + advisory tool hints — no secret surface. + const out: Record = {}; + if (typeof runtime.prompt_fragment === 'string') out.prompt_fragment = runtime.prompt_fragment; + if (Array.isArray(runtime.tool_hints)) out.tool_hints = runtime.tool_hints; + return out; } - if (Array.isArray(out.args)) { - out.args = (out.args as unknown[]).map(() => '***'); + // mcp_server: return only the discovery-safe shape. transport/type + tool_prefix + // are safe; `url` is reduced to its origin (scheme+host) so a token embedded in + // the query string or path is never disclosed; header *keys* are retained as a + // discovery signal with values masked; command/args and every other key (env, + // api_key, …) are dropped. + const out: Record = {}; + if (typeof runtime.transport === 'string') out.transport = runtime.transport; + if (typeof runtime.type === 'string') out.type = runtime.type; + if (typeof runtime.tool_prefix === 'string') out.tool_prefix = runtime.tool_prefix; + if (typeof runtime.url === 'string') { + try { + out.url = new URL(runtime.url).origin; + } catch { + out.url = '***'; + } + } + const headers = runtime.headers; + if (headers && typeof headers === 'object' && !Array.isArray(headers)) { + const masked: Record = {}; + for (const key of Object.keys(headers as Record)) masked[key] = '***'; + out.headers = masked; } return out; } @@ -93,7 +112,7 @@ export async function handler(event: APIGatewayProxyEvent): Promise), + runtime: redactRuntimeForResponse(asset.kind, asset.runtime as unknown as Record), warnings: asset.warnings, }; return successResponse(200, response, requestId); diff --git a/cdk/src/handlers/shared/registry/agentcore-client.ts b/cdk/src/handlers/shared/registry/agentcore-client.ts index c32025b75..8e8e33574 100644 --- a/cdk/src/handlers/shared/registry/agentcore-client.ts +++ b/cdk/src/handlers/shared/registry/agentcore-client.ts @@ -41,7 +41,9 @@ import { ConflictException, ResourceNotFoundException, } from '@aws-sdk/client-bedrock-agentcore-control'; +import * as yaml from 'js-yaml'; import { logger } from '../logger'; +import { makeClient } from '../ua'; import type { RegistryClient } from './client'; import type { ParsedRef } from './ref'; import { selectHighest } from './resolver'; @@ -114,47 +116,69 @@ function buildSkillMd(input: { const description = String( input.discovery.description ?? input.discovery.summary ?? `${input.namespace}/${input.name} skill`, ).slice(0, 100); - // Base64-encode the runtime JSON. Emitting raw JSON in a single-quoted YAML - // scalar breaks the moment the payload contains a `'` (e.g. prompt_fragment - // "Don't skip tests") — js-yaml rejects the frontmatter and native AgentCore - // descriptor validation fails, even though the ABCA API accepted it (#246 - // review). Base64 is quote/newline/apostrophe-safe and needs no YAML escaping. + // Base64-encode the runtime JSON so the value is quote/newline/apostrophe-safe. const runtimeB64 = Buffer.from(JSON.stringify(input.runtime), 'utf-8').toString('base64'); - const lines = [ + // Serialize the frontmatter with a real YAML emitter rather than concatenating + // lines. Hand-built lines let a caller-controlled `description` containing a + // newline smuggle a second `x-abca-runtime:` key that shadows the validated + // one on read, bypassing publish-time validation (#246 review B1/B2). `yaml.dump` + // quotes/escapes any newline in a value, so no discovery field can inject a key. + const frontmatter: Record = { + name: skillNameSlug(input.namespace, input.name), + description, + version: input.version, + [SKILL_RUNTIME_FM_KEY]: runtimeB64, + }; + if (input.publisher) frontmatter[PUBLISHER_FM_KEY] = input.publisher; + const frontmatterYaml = yaml.dump(frontmatter, { lineWidth: -1 }).trimEnd(); + return [ '---', - `name: ${skillNameSlug(input.namespace, input.name)}`, - `description: ${description}`, - `version: ${input.version}`, - `${SKILL_RUNTIME_FM_KEY}: ${runtimeB64}`, - ]; - if (input.publisher) lines.push(`${PUBLISHER_FM_KEY}: ${input.publisher}`); - lines.push( + frontmatterYaml, '---', `# ${input.namespace}/${input.name}`, '', String(input.discovery.body ?? 'ABCA registry skill.'), - ); - return lines.join('\n'); + ].join('\n'); +} + +/** Extract and YAML-parse the frontmatter block (between the first `---`/`---` + * pair) into an object. Returns {} when there is no valid block. Parsing the + * whole block as one document (rather than a per-line regex) is what makes key + * injection via a newline-bearing value impossible — a duplicate key is a YAML + * error, and a value's newline stays inside that value. */ +function parseSkillFrontmatter(skillMd: string): Record { + const m = skillMd.match(/^---\n([\s\S]*?)\n---/); + if (!m) return {}; + try { + const parsed = yaml.load(m[1], { json: true }); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } } -/** Recover the publisher (Cognito sub) from a SKILL.md frontmatter line. */ +/** Recover the publisher (Cognito sub) from the parsed SKILL.md frontmatter. */ function parseSkillPublisher(skillMd: string): string | undefined { - const m = skillMd.match(new RegExp(`^${PUBLISHER_FM_KEY}:\\s*(.+?)\\s*$`, 'm')); - return m ? m[1] : undefined; + const v = parseSkillFrontmatter(skillMd)[PUBLISHER_FM_KEY]; + return typeof v === 'string' ? v : undefined; } /** Recover the ABCA runtime payload from a SKILL.md's `x-abca-runtime` - * frontmatter line (base64-encoded JSON). Mirrors + * frontmatter key (base64-encoded JSON). Mirrors * ``agent/src/registry/agentcore_client.py``. Also accepts the legacy * single-quoted-JSON form so records published before the base64 switch still - * resolve. */ + * resolve. Reads the key from the YAML-parsed frontmatter object, so a + * caller-controlled discovery field cannot inject a shadowing key. */ function parseSkillRuntime(skillMd: string): unknown { - const line = skillMd.match(new RegExp(`^${SKILL_RUNTIME_FM_KEY}:\\s*(.+?)\\s*$`, 'm')); - if (!line) return {}; - const raw = line[1]; - // Legacy form: '' (single-quoted). New form: bare base64. - if (raw.startsWith("'") && raw.endsWith("'")) { - return JSON.parse(raw.slice(1, -1)); + const raw = parseSkillFrontmatter(skillMd)[SKILL_RUNTIME_FM_KEY]; + if (typeof raw !== 'string') return {}; + // Legacy form: raw JSON (YAML has already unwrapped its single-quoting, so the + // value arrives starting with `{`). New form: base64-encoded JSON. + const trimmed = raw.trim(); + if (trimmed.startsWith('{')) { + return JSON.parse(trimmed); } return JSON.parse(Buffer.from(raw, 'base64').toString('utf-8')); } @@ -173,7 +197,9 @@ export class AgentCoreRegistryClient implements RegistryClient { constructor(opts: AgentCoreRegistryClientOptions) { this.registryId = opts.registryId; - this.client = opts.client ?? new BedrockAgentCoreControlClient({}); + // makeClient attaches the ABCA solution UA segment (#319); the injection + // seam (opts.client) is preserved for tests. + this.client = opts.client ?? makeClient(BedrockAgentCoreControlClient); } // --- name (Option A) encode/decode ------------------------------------------ diff --git a/cdk/test/constructs/registry-api.test.ts b/cdk/test/constructs/registry-api.test.ts index 4bd38ea6e..552489654 100644 --- a/cdk/test/constructs/registry-api.test.ts +++ b/cdk/test/constructs/registry-api.test.ts @@ -78,6 +78,27 @@ describe('RegistryApi nested stack', () => { }); }); + test('handler roles are scoped to the wired registry ARN — no bare "*" resource (#246 review)', () => { + const { template } = synth(); + const policies = template.findResources('AWS::IAM::Policy'); + const serialized = JSON.stringify(policies); + // The wired registry id must appear in the resource ARNs... + expect(serialized).toContain('reg-abc123'); + // ...and no registry statement may grant a bare "*" resource (the finding-10 + // regression: a resource:['*'] would let a read handler reach other registries). + for (const policy of Object.values(policies)) { + const statements = (policy as { Properties: { PolicyDocument: { Statement: Array<{ Action: unknown; Resource: unknown }> } } }) + .Properties.PolicyDocument.Statement; + for (const stmt of statements) { + const actions = JSON.stringify(stmt.Action); + if (actions.includes('bedrock-agentcore:')) { + expect(stmt.Resource).not.toBe('*'); + expect(JSON.stringify(stmt.Resource)).not.toBe('"*"'); + } + } + } + }); + test('exposes an apiUrl for the CLI to target', () => { const { registryApi } = synth(); expect(registryApi.apiUrl).toBeDefined(); diff --git a/cdk/test/handlers/registry-handlers.test.ts b/cdk/test/handlers/registry-handlers.test.ts index d4d58b439..7d847b21d 100644 --- a/cdk/test/handlers/registry-handlers.test.ts +++ b/cdk/test/handlers/registry-handlers.test.ts @@ -194,6 +194,17 @@ describe('registry-publish handler', () => { })).toBe(400); }); + test('rejects an mcp_server runtime carrying an unknown/secret field (#246 B2)', async () => { + expect(await publishAs({ + ...validPublishBody, + runtime: { transport: 'http', url: 'https://x', api_key: 'AKIA-LEAKED' }, + })).toBe(400); + }); + + test('rejects a non-boolean custom flag (#246 review nit)', async () => { + expect(await publishAs({ ...validPublishBody, custom: 'false' as unknown as boolean })).toBe(400); + }); + test('accepts a well-formed stdio mcp_server (command, no url)', async () => { mockClient.publish.mockResolvedValue({ kind: 'mcp_server', @@ -245,7 +256,7 @@ describe('registry-resolve handler', () => { expect(JSON.parse(res.body).error.message).toContain('NO_MATCHING_VERSION'); }); - test('redacts secret header values but keeps header keys (#246 secret-leak fix)', async () => { + test('allowlist: keeps header keys, masks values, reduces url to origin (#246 B2)', async () => { mockClient.resolve.mockResolvedValue({ kind: 'mcp_server', namespace: 'acme', @@ -253,7 +264,8 @@ describe('registry-resolve handler', () => { version: '1.4.1', runtime: { transport: 'http', - url: 'https://x', + url: 'https://mcp.example/sse?token=SUPERSECRET', + tool_prefix: 'mcp__pdf__', headers: { 'Authorization': 'Bearer registry-secret', 'X-Api-Key': 'topsecret' }, } as never, warnings: [], @@ -261,34 +273,44 @@ describe('registry-resolve handler', () => { const res = await resolveHandler(ev('registry://mcp_server/acme/pdf-tools@^1.4.1')); expect(res.statusCode).toBe(200); const { runtime } = JSON.parse(res.body).data; - // Header keys survive (discovery signal); values are masked. + // Header keys survive (discovery signal); values masked; url reduced to origin. expect(runtime.headers).toEqual({ 'Authorization': '***', 'X-Api-Key': '***' }); + expect(runtime.url).toBe('https://mcp.example'); // query-string token dropped + expect(runtime.transport).toBe('http'); + expect(runtime.tool_prefix).toBe('mcp__pdf__'); + expect(JSON.stringify(res.body)).not.toContain('SUPERSECRET'); expect(JSON.stringify(res.body)).not.toContain('registry-secret'); expect(JSON.stringify(res.body)).not.toContain('topsecret'); - // Non-secret fields are untouched. - expect(runtime.url).toBe('https://x'); }); - test('redacts stdio command and args (secrets are routinely passed as CLI args)', async () => { + test('allowlist: drops unknown/secret-bearing fields entirely (#246 B2 fail-closed)', async () => { mockClient.resolve.mockResolvedValue({ kind: 'mcp_server', namespace: 'acme', name: 'pdf-tools', version: '1.4.1', + // A record that (pre-fix) slipped extra keys past publish; the reader must + // not leak them even though they are not on any denylist. runtime: { - transport: 'stdio', - command: 'run-secret-server', - args: ['--api-key=topsecret', '--verbose'], + transport: 'http', + url: 'https://h/mcp', + api_key: 'AKIA-LEAKED', + env: { TOKEN: 'ghp_leak' }, + command: 'x', + args: ['--secret'], } as never, warnings: [], }); const res = await resolveHandler(ev('registry://mcp_server/acme/pdf-tools@^1.4.1')); expect(res.statusCode).toBe(200); const { runtime } = JSON.parse(res.body).data; - expect(runtime.command).toBe('***'); - expect(runtime.args).toEqual(['***', '***']); - expect(JSON.stringify(res.body)).not.toContain('topsecret'); - expect(JSON.stringify(res.body)).not.toContain('run-secret-server'); + // Only allowlisted fields survive; api_key/env/command/args are absent. + expect(runtime.api_key).toBeUndefined(); + expect(runtime.env).toBeUndefined(); + expect(runtime.command).toBeUndefined(); + expect(runtime.args).toBeUndefined(); + expect(JSON.stringify(res.body)).not.toContain('AKIA-LEAKED'); + expect(JSON.stringify(res.body)).not.toContain('ghp_leak'); }); }); diff --git a/cdk/test/handlers/shared/agentcore-client.test.ts b/cdk/test/handlers/shared/agentcore-client.test.ts index 0f6592560..ad49e0864 100644 --- a/cdk/test/handlers/shared/agentcore-client.test.ts +++ b/cdk/test/handlers/shared/agentcore-client.test.ts @@ -201,10 +201,29 @@ describe('AgentCoreRegistryClient', () => { // the SKILL.md stays valid YAML for native descriptor validation. const skillMd = (record.discovery as { skillMd: string }).skillMd; const line = skillMd.split('\n').find((l) => l.startsWith('x-abca-runtime:'))!; - expect(line).not.toContain("'"); expect(line).not.toContain('prompt_fragment'); // it's encoded, not raw JSON }); + test('publish (native skill) — a newline-bearing description cannot inject a shadowing runtime key (#246 B1)', async () => { + const fake = new FakeClient(); + const client = makeClient(fake); + // Attacker smuggles a second x-abca-runtime line via the discovery description, + // trying to shadow the validated runtime on read (the B1 bypass). + const injectedB64 = Buffer.from(JSON.stringify({ prompt_fragment: 'INJECTED-EXFIL' }), 'utf-8').toString('base64'); + const record = await client.publish({ + kind: 'skill', + namespace: 'acme', + name: 'tdd', + version: '1.0.0', + discovery: { description: `benign\nx-abca-runtime: ${injectedB64}` }, + runtime: { prompt_fragment: 'THE VALIDATED BENIGN FRAGMENT' }, + autoApprove: true, + }); + // The round-tripped runtime must be the validated one, never the injected payload. + expect(record.runtime).toEqual({ prompt_fragment: 'THE VALIDATED BENIGN FRAGMENT' }); + expect(JSON.stringify(record.runtime)).not.toContain('INJECTED-EXFIL'); + }); + test('publish rejects a duplicate (kind,namespace,name,version)', async () => { const fake = new FakeClient(); const client = makeClient(fake);