diff --git a/agent/src/models.py b/agent/src/models.py index d7687c4e7..f12f008a0 100644 --- a/agent/src/models.py +++ b/agent/src/models.py @@ -221,6 +221,12 @@ class TaskConfig(BaseModel): trace: bool = False # Enriched mid-flight by pipeline.py: cedar_policies: list[str] = [] + # Registry (#246): the resolved asset bundle threaded from the orchestrator + # payload โ€” ``{mcp_servers, cedar_policy_modules, skills}`` each a list of + # resolved-asset dicts. Applied by registry_loader.py at task start (MVP: + # mcp_servers merged into .mcp.json; cedar/skills staged for PR 3). Empty + # dict when the blueprint pinned no assets. + resolved_assets: dict[str, list[dict]] = {} # Cedar HITL (ยง7.3, ยง10.2). Per-task approval defaults threaded # from the orchestrator payload; consumed by PolicyEngine at # construction so the engine seeds ApprovalAllowlist and adopts diff --git a/agent/src/pipeline.py b/agent/src/pipeline.py index 91aa46e3a..0b3385045 100644 --- a/agent/src/pipeline.py +++ b/agent/src/pipeline.py @@ -40,6 +40,7 @@ ) from progress_writer import _ProgressWriter from prompt_builder import build_system_prompt, discover_project_config +from registry_loader import apply_resolved_assets from shell import log, log_error_cw from telemetry import ( _TrajectoryWriter, @@ -616,6 +617,7 @@ def run_task( branch_name: str = "", pr_number: str = "", cedar_policies: list[str] | None = None, + resolved_assets: dict[str, list[dict]] | None = None, approval_timeout_s: int | None = None, initial_approvals: list[str] | None = None, initial_approval_gate_count: int = 0, @@ -670,6 +672,12 @@ def run_task( if cedar_policies: config.cedar_policies = cedar_policies + # Registry (#246): thread the resolved asset bundle onto config so the + # loader (below, after channel MCP wiring) can apply it. Same post-build + # injection pattern as cedar_policies. + if resolved_assets: + config.resolved_assets = resolved_assets + # Export session-tag values so tenant-data boto3 clients (DDB/S3) assume # the per-task SessionRole with {user_id, repo, task_id} tags. No-op when # AGENT_SESSION_ROLE_ARN is unset (local/dev/tests). @@ -865,6 +873,12 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: resolve_jira_oauth_token(config.channel_metadata) configure_channel_mcp(setup.repo_dir, config.channel_source) + # Registry (#246): apply resolved assets AFTER the channel MCP so a + # registry-published MCP server merges alongside (not over) the + # channel entry in .mcp.json, and both are present before + # discover_project_config scans. No-op when no assets were pinned. + apply_resolved_assets(setup.repo_dir, config.resolved_assets) + # ๐Ÿ‘€ on the Linear issue โ€” acknowledges the task is picked up. # No-op for non-Linear tasks. Best-effort; failures are logged # but do not block the pipeline. Capture the reaction id so we diff --git a/agent/src/prompt_builder.py b/agent/src/prompt_builder.py index dd9a7ca9b..08a41f9f7 100644 --- a/agent/src/prompt_builder.py +++ b/agent/src/prompt_builder.py @@ -76,6 +76,10 @@ def build_system_prompt( if channel_addendum: system_prompt += channel_addendum + # Registry (#246) skill fragments โ€” appended after channel guidance so + # skill instructions also benefit from recency weighting. + system_prompt += _registry_skill_addendum(config) + return system_prompt @@ -109,9 +113,38 @@ def build_repoless_system_prompt( if channel_addendum: system_prompt += channel_addendum + system_prompt += _registry_skill_addendum(config) + return system_prompt +def _registry_skill_addendum(config: TaskConfig) -> str: + """Return the appended prompt text for registry-resolved skills (#246), or "". + + Each resolved ``skill`` asset carries a ``content`` prompt fragment (resolved + from the descriptor; see REGISTRY.md ยง8) plus ``tool_hints`` in its + descriptor. We render one section per skill so the agent picks up the + guidance; ``tool_hints`` are advisory text (a skill cannot invoke tools โ€” it + only references tools an MCP server separately provides). + """ + skills = (config.resolved_assets or {}).get("skills") or [] + sections: list[str] = [] + for skill in skills: + content = skill.get("content") + if not isinstance(content, str) or not content: + continue + name = f"{skill.get('namespace')}/{skill.get('name')}" + descriptor = skill.get("descriptor") or {} + hints = descriptor.get("tool_hints") + hint_line = "" + if isinstance(hints, list) and hints: + hint_line = f"\n\n_Suggested tools: {', '.join(str(h) for h in hints)}._" + sections.append(f"\n\n## Skill: {name}\n\n{content}{hint_line}") + if sections: + log("TASK", f"registry: applied {len(sections)} skill fragment(s) to the system prompt") + return "".join(sections) + + def _render_memory_context(hydrated_context: HydratedContext | None) -> str: """Render the memory-context block shared by repo-bound and repo-less prompts.""" if not (hydrated_context and hydrated_context.memory_context): diff --git a/agent/src/registry_loader.py b/agent/src/registry_loader.py new file mode 100644 index 000000000..344dd8d68 --- /dev/null +++ b/agent/src/registry_loader.py @@ -0,0 +1,178 @@ +"""Apply registry-resolved assets (#246) to the agent's runtime environment. + +The orchestrator resolves a blueprint's ``registry://`` pins at the create-task +boundary and threads a ``resolved_assets`` bundle into the invocation payload +(see docs/design/REGISTRY.md ยง7/ยง8). This module is the agent-side consumer: +it takes that bundle and applies each asset kind with the right mechanism. + +Each asset kind lands where it is consumed: +- ``mcp_server`` โ†’ merged into ``/.mcp.json`` **here**, alongside + whatever ``channel_mcp.py`` wrote, so the Claude Agent SDK + (``setting_sources=["project"]``) picks it up at session start. +- ``cedar_policy_module`` โ†’ applied ORCHESTRATOR-side (the resolved text is + merged into the ``cedar_policies`` payload, byte-identical to inline policies); + the loader below is log-only. +- ``skill`` โ†’ applied at system-prompt assembly + (``prompt_builder._registry_skill_addendum``); the loader below is log-only. + +Design note โ€” parity with channel_mcp.py: both write ``.mcp.json`` by +read-merge-write on the ``mcpServers`` map, never clobbering other servers. +Registry assets and the channel MCP can therefore coexist in one file. +""" + +from __future__ import annotations + +import json +import os +from typing import Any + +from shell import log + +#: Key under which the resolved MCP server config lives in the descriptor +#: (REGISTRY.md ยง3.3). The value is a ready-to-write ``mcpServers`` entry. +_MCP_SERVER_CONFIG_KEY = "server_config" + + +def _read_existing_mcp_config(path: str) -> dict[str, Any]: + """Return the parsed .mcp.json at ``path``, or an empty dict if absent/invalid. + + Mirrors ``channel_mcp._read_existing_mcp_config`` โ€” malformed JSON is logged + and treated as absent rather than crashing the agent on a broken file. + """ + if not os.path.isfile(path): + return {} + try: + with open(path, encoding="utf-8") as f: + parsed = json.load(f) + if isinstance(parsed, dict): + return parsed + log("WARN", f"Ignoring non-object .mcp.json at {path} (got {type(parsed).__name__})") + except (OSError, json.JSONDecodeError) as e: + log("WARN", f"Failed to read existing .mcp.json at {path}: {type(e).__name__}: {e}") + return {} + + +def _mcp_server_key(asset: dict[str, Any]) -> str: + """The ``mcpServers`` key an asset registers under. + + Prefer the descriptor's ``tool_prefix`` stripped of the ``mcp__`` scaffolding + (so tools surface consistently), falling back to ``namespace-name`` which is + always present and unique per asset. + """ + namespace = asset.get("namespace", "") + name = asset.get("name", "") + return f"{namespace}-{name}".strip("-") or name or "registry-mcp" + + +def apply_mcp_assets(repo_dir: str, resolved_mcp_servers: list[dict[str, Any]]) -> int: + """Merge resolved MCP server assets into ``/.mcp.json``. + + Read-merge-write on the ``mcpServers`` map, preserving any channel MCP or + repo-committed entries. Each asset's descriptor carries a ``server_config`` + (the ``mcpServers`` entry value); assets missing it are skipped with a warn + rather than writing a malformed entry. + + Args: + repo_dir: the cloned-repo working directory the SDK uses as ``cwd``. + resolved_mcp_servers: the ``mcp_servers`` list from the resolved bundle; + each item is a resolved-asset dict (kind/namespace/name/version/descriptor). + + Returns: + The number of MCP entries written (0 when nothing to apply, or on a + write failure โ€” logged). + """ + if not resolved_mcp_servers: + return 0 + if not repo_dir or not os.path.isdir(repo_dir): + log("WARN", f"apply_mcp_assets: repo_dir missing or not a directory: {repo_dir!r}") + return 0 + + mcp_path = os.path.join(repo_dir, ".mcp.json") + config = _read_existing_mcp_config(mcp_path) + servers = config.get("mcpServers") + if not isinstance(servers, dict): + servers = {} + + written = 0 + for asset in resolved_mcp_servers: + descriptor = asset.get("descriptor") or {} + server_config = descriptor.get(_MCP_SERVER_CONFIG_KEY) + if not isinstance(server_config, dict): + log( + "WARN", + f"apply_mcp_assets: asset {asset.get('namespace')}/{asset.get('name')} " + f"has no '{_MCP_SERVER_CONFIG_KEY}' in its descriptor; skipping", + ) + continue + servers[_mcp_server_key(asset)] = server_config + written += 1 + + if written == 0: + return 0 + + config["mcpServers"] = servers + try: + with open(mcp_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + f.write("\n") + except OSError as e: + log("ERROR", f"apply_mcp_assets: failed to write {mcp_path}: {e}") + return 0 + + log("TASK", f"registry: merged {written} MCP server asset(s) into {mcp_path}") + return written + + +def apply_cedar_modules(resolved_cedar_modules: list[dict[str, Any]]) -> None: + """Log resolved cedar_policy_module assets (#246). + + Cedar text is applied ORCHESTRATOR-side: the resolved module ``content`` is + merged into the payload's ``cedar_policies`` list before it reaches the + agent, so registry-sourced Cedar is byte-identical to inline blueprint + policies and flows through the same ``PolicyEngine(extra_policies=...)`` + path (REGISTRY.md ยง8). The agent therefore does NOT re-apply Cedar here โ€” + doing so would double-load it. This only surfaces what resolved so it shows + in the task log alongside the MCP/skill loaders. + """ + if resolved_cedar_modules: + names = ", ".join( + f"{a.get('namespace')}/{a.get('name')}@{a.get('version')}" + for a in resolved_cedar_modules + ) + log( + "TASK", + f"registry: {len(resolved_cedar_modules)} cedar_policy_module asset(s) " + f"applied via cedar_policies payload: {names}", + ) + + +def apply_skills(repo_dir: str, resolved_skills: list[dict[str, Any]]) -> None: + """Log resolved skill assets (#246). + + Skill prompt fragments are applied when the system prompt is assembled + (``prompt_builder._registry_skill_addendum``), not here โ€” a skill is prompt + text, so it must land in the prompt, and the prompt is built earlier in the + pipeline than this loader runs. This only surfaces what resolved so it shows + in the task log alongside the MCP/cedar loaders. + """ + if resolved_skills: + names = ", ".join( + f"{a.get('namespace')}/{a.get('name')}@{a.get('version')}" for a in resolved_skills + ) + log("TASK", f"registry: {len(resolved_skills)} skill asset(s) applied to prompt: {names}") + + +def apply_resolved_assets(repo_dir: str, resolved_assets: dict[str, list[dict]]) -> None: + """Apply an entire resolved-asset bundle to the runtime. + + Dispatches each kind. MCP servers are merged into ``.mcp.json`` here; Cedar + modules are applied orchestrator-side (merged into ``cedar_policies``) and + skills are applied at system-prompt assembly โ€” the cedar/skill calls below + are log-only for those, since their content lands elsewhere in the pipeline. + Safe to call with an empty bundle (no-op). + """ + if not resolved_assets: + return + apply_mcp_assets(repo_dir, resolved_assets.get("mcp_servers") or []) + apply_cedar_modules(resolved_assets.get("cedar_policy_modules") or []) + apply_skills(repo_dir, resolved_assets.get("skills") or []) diff --git a/agent/src/server.py b/agent/src/server.py index a7ae76ce0..e77d6a0f9 100644 --- a/agent/src/server.py +++ b/agent/src/server.py @@ -391,6 +391,7 @@ def _run_task_background( branch_name: str = "", pr_number: str = "", cedar_policies: list[str] | None = None, + resolved_assets: dict[str, list[dict]] | None = None, approval_timeout_s: int | None = None, initial_approvals: list[str] | None = None, initial_approval_gate_count: int = 0, @@ -477,6 +478,7 @@ def _run_task_background( branch_name=branch_name, pr_number=pr_number, cedar_policies=cedar_policies, + resolved_assets=resolved_assets, approval_timeout_s=approval_timeout_s, initial_approvals=initial_approvals, initial_approval_gate_count=initial_approval_gate_count, @@ -531,6 +533,10 @@ def _extract_invocation_params(inp: dict, request: Request) -> dict: branch_name = inp.get("branch_name", "") pr_number = str(inp.get("pr_number", "")) cedar_policies = inp.get("cedar_policies") or [] + # Registry (#246): the resolved asset bundle the orchestrator stamped after + # resolving the blueprint's pins. Absent when no assets were pinned; the + # loader treats an empty dict as a no-op. + resolved_assets = inp.get("resolved_assets") or {} # Cedar HITL (ยง7.3) โ€” per-task approval defaults + seeded allowlist. # Both are forwarded verbatim to the pipeline; the engine # validates shape at construction time and raises on bad input. @@ -637,6 +643,7 @@ def _extract_invocation_params(inp: dict, request: Request) -> dict: "branch_name": branch_name, "pr_number": pr_number, "cedar_policies": cedar_policies, + "resolved_assets": resolved_assets, "approval_timeout_s": approval_timeout_s, "initial_approvals": initial_approvals, "initial_approval_gate_count": initial_approval_gate_count, diff --git a/agent/src/workflow/__init__.py b/agent/src/workflow/__init__.py index 9ffcef994..a1ba8f941 100644 --- a/agent/src/workflow/__init__.py +++ b/agent/src/workflow/__init__.py @@ -42,7 +42,7 @@ gate_status, run_workflow, ) -from .validator import assert_valid, validate_workflow +from .validator import assert_valid, is_registry_ref, validate_workflow __all__ = [ "STEP_HANDLERS", @@ -62,6 +62,7 @@ "WorkflowValidationError", "assert_valid", "gate_status", + "is_registry_ref", "load_workflow", "load_workflow_file", "policy_principal_for", diff --git a/agent/src/workflow/validator.py b/agent/src/workflow/validator.py index 25f2a6eeb..ede3aeedf 100644 --- a/agent/src/workflow/validator.py +++ b/agent/src/workflow/validator.py @@ -46,8 +46,23 @@ # 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). +# +# _REGISTRY_REF is the #246 asset grammar (docs/design/REGISTRY.md ยง6, ADR-018): +# registry:////@ +# The kind segment is snake_case (mcp_server, cedar_policy_module) and the +# @ pin is MANDATORY (fail-closed, ADR-018 sub-decision 6) โ€” exact, +# caret, or tilde semver only; floating (*, latest, >=) is rejected. This regex +# MUST stay byte-for-byte identical to the TypeScript ``parseRef`` +# (cdk/src/handlers/shared/registry-resolver.ts); the ``contracts/registry-resolution/`` +# corpus is the agreement both sides reproduce. _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://" + r"[a-z][a-z0-9_]*/" # kind (snake_case) + r"[a-z][a-z0-9-]*/" # namespace + r"[a-z0-9][a-z0-9._-]*" # name + r"@[\^~]?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$" # @constraint (exact/caret/tilde) +) # 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). @@ -113,6 +128,16 @@ def _ref_resolves(ref: str) -> bool: return bool(_BUILTIN_REF.match(ref) or _REGISTRY_REF.match(ref)) +def is_registry_ref(ref: str) -> bool: + """True iff ``ref`` matches the #246 registry URI grammar (REGISTRY.md ยง6). + + Public entry point for the ``contracts/registry-resolution/`` parity corpus: + this MUST agree byte-for-byte with the TypeScript ``parseRef``. Does not + match ``builtin/*`` โ€” grammar only, not resolvability. + """ + return bool(_REGISTRY_REF.match(ref)) + + # --- the rules --------------------------------------------------------------- # Each returns a list of human-readable messages (empty == passed). Rule numbers # match WORKFLOWS.md ยง"Validation rules". Rules 3/4/7 also have a schema half; diff --git a/agent/tests/test_registry_grammar_corpus.py b/agent/tests/test_registry_grammar_corpus.py new file mode 100644 index 000000000..af13de74b --- /dev/null +++ b/agent/tests/test_registry_grammar_corpus.py @@ -0,0 +1,65 @@ +"""Registry URI grammar parity โ€” agent side (#246). + +Loads every ``contracts/registry-resolution/grammar-*.json`` fixture and asserts +that ``workflow.is_registry_ref(ref)`` agrees with the fixture's ``expected.valid``. +This is the golden contract that the TypeScript ``parseRef`` +(cdk/src/handlers/shared/registry-resolver.ts) must also reproduce โ€” mirroring +the cross-engine pattern in ``test_cedar_parity.py`` and the workflow-validation +corpus. + +If the regex and a fixture disagree, CI fails before the change ships: either +the grammar regressed, or the fixture must be updated as a recorded decision. +See ``contracts/registry-resolution/README.md`` and REGISTRY.md ยง6. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +import pytest + +from workflow import is_registry_ref + +_FIXTURE_DIR = ( + Path(os.path.dirname(__file__)) / ".." / ".." / "contracts" / "registry-resolution" +).resolve() + + +def _load_grammar_fixtures() -> list[dict]: + assert _FIXTURE_DIR.is_dir(), ( + f"expected fixture dir at {_FIXTURE_DIR}; see contracts/registry-resolution/README.md" + ) + fixtures = [] + for path in sorted(_FIXTURE_DIR.glob("grammar-*.json")): + fixture = json.loads(path.read_text(encoding="utf-8")) + for required in ("name", "ref", "expected"): + if required not in fixture: + raise AssertionError(f"{path.name}: missing required field {required!r}") + if "valid" not in fixture["expected"]: + raise AssertionError(f"{path.name}: expected missing 'valid'") + fixtures.append(fixture) + assert fixtures, f"no grammar-*.json fixtures under {_FIXTURE_DIR}" + return fixtures + + +_FIXTURES = _load_grammar_fixtures() + + +@pytest.mark.parametrize("fixture", _FIXTURES, ids=[f["name"] for f in _FIXTURES]) +def test_grammar_matches_fixture(fixture: dict) -> None: + observed = is_registry_ref(fixture["ref"]) + expected = fixture["expected"]["valid"] + assert observed == expected, ( + f"fixture {fixture['name']!r}: grammar drift โ€” is_registry_ref({fixture['ref']!r}) " + f"returned {observed}, fixture expects {expected}" + ) + + +def test_corpus_covers_valid_and_invalid() -> None: + """The corpus must exercise both accept and reject paths.""" + valids = [f for f in _FIXTURES if f["expected"]["valid"]] + invalids = [f for f in _FIXTURES if not f["expected"]["valid"]] + assert valids, "grammar corpus must include at least one valid ref" + assert invalids, "grammar corpus must include at least one invalid ref" diff --git a/agent/tests/test_registry_loader.py b/agent/tests/test_registry_loader.py new file mode 100644 index 000000000..795d372d4 --- /dev/null +++ b/agent/tests/test_registry_loader.py @@ -0,0 +1,127 @@ +"""Tests for the agent-side registry asset loader (#246).""" + +from __future__ import annotations + +import json +import os + +from registry_loader import ( + apply_cedar_modules, + apply_mcp_assets, + apply_resolved_assets, + apply_skills, +) + + +def _read_mcp(repo_dir: str) -> dict: + with open(os.path.join(repo_dir, ".mcp.json"), encoding="utf-8") as f: + return json.load(f) + + +def _mcp_asset(name: str, server_config: dict, namespace: str = "acme") -> dict: + return { + "kind": "mcp_server", + "namespace": namespace, + "name": name, + "version": "1.0.0", + "descriptor": {"summary": "s", "permissions": [], "server_config": server_config}, + "warnings": [], + } + + +class TestApplyMcpAssets: + def test_no_op_on_empty_list(self, tmp_path): + assert apply_mcp_assets(str(tmp_path), []) == 0 + assert not (tmp_path / ".mcp.json").exists() + + def test_writes_resolved_server_into_mcp_json(self, tmp_path): + cfg = {"type": "http", "url": "https://mcp.example/pdf"} + written = apply_mcp_assets(str(tmp_path), [_mcp_asset("pdf-tools", cfg)]) + assert written == 1 + servers = _read_mcp(str(tmp_path))["mcpServers"] + assert servers["acme-pdf-tools"] == cfg + + def test_merges_alongside_existing_channel_entry(self, tmp_path): + # Simulate channel_mcp.py having already written a linear-server entry. + (tmp_path / ".mcp.json").write_text( + json.dumps({"mcpServers": {"linear-server": {"type": "http", "url": "x"}}}) + ) + apply_mcp_assets(str(tmp_path), [_mcp_asset("pdf-tools", {"type": "http", "url": "y"})]) + servers = _read_mcp(str(tmp_path))["mcpServers"] + # Both coexist โ€” the channel entry is not clobbered. + assert "linear-server" in servers + assert "acme-pdf-tools" in servers + + def test_writes_multiple_assets(self, tmp_path): + written = apply_mcp_assets( + str(tmp_path), + [_mcp_asset("a", {"url": "1"}), _mcp_asset("b", {"url": "2"})], + ) + assert written == 2 + assert len(_read_mcp(str(tmp_path))["mcpServers"]) == 2 + + def test_skips_asset_missing_server_config(self, tmp_path): + bad = { + "kind": "mcp_server", + "namespace": "acme", + "name": "broken", + "version": "1.0.0", + "descriptor": {"summary": "s", "permissions": []}, # no server_config + "warnings": [], + } + assert apply_mcp_assets(str(tmp_path), [bad]) == 0 + + def test_no_op_on_missing_repo_dir(self): + assert apply_mcp_assets("/nonexistent/dir/xyz", [_mcp_asset("a", {"url": "1"})]) == 0 + + def test_tolerates_malformed_existing_mcp_json(self, tmp_path): + (tmp_path / ".mcp.json").write_text("{ not json") + written = apply_mcp_assets(str(tmp_path), [_mcp_asset("pdf", {"url": "z"})]) + assert written == 1 + assert "acme-pdf" in _read_mcp(str(tmp_path))["mcpServers"] + + +class TestLogOnlyLoaders: + # Cedar text is applied orchestrator-side (merged into cedar_policies) and + # skill fragments at system-prompt assembly, so these loaders are log-only โ€” + # they return None and must not raise. + def test_cedar_modules_is_noop(self): + asset = { + "kind": "cedar_policy_module", + "namespace": "acme", + "name": "guard", + "version": "1.0.0", + } + assert apply_cedar_modules([asset]) is None + + def test_cedar_modules_empty_is_noop(self): + assert apply_cedar_modules([]) is None + + def test_skills_is_noop(self, tmp_path): + assert ( + apply_skills( + str(tmp_path), + [ + {"kind": "skill", "namespace": "acme", "name": "refactor", "version": "1.0.0"}, + ], + ) + is None + ) + + def test_skills_empty_is_noop(self, tmp_path): + assert apply_skills(str(tmp_path), []) is None + + +class TestApplyResolvedAssets: + def test_empty_bundle_is_noop(self, tmp_path): + apply_resolved_assets(str(tmp_path), {}) + assert not (tmp_path / ".mcp.json").exists() + + def test_dispatches_mcp_servers(self, tmp_path): + bundle = { + "mcp_servers": [_mcp_asset("pdf-tools", {"type": "http", "url": "u"})], + "cedar_policy_modules": [], + "skills": [], + } + apply_resolved_assets(str(tmp_path), bundle) + assert "acme-pdf-tools" in _read_mcp(str(tmp_path))["mcpServers"] diff --git a/agent/tests/test_registry_skill_prompt.py b/agent/tests/test_registry_skill_prompt.py new file mode 100644 index 000000000..8f58c1abc --- /dev/null +++ b/agent/tests/test_registry_skill_prompt.py @@ -0,0 +1,51 @@ +"""Tests for registry skill fragments in the system prompt (#246, PR 3).""" + +from __future__ import annotations + +from prompt_builder import _registry_skill_addendum +from tests.conftest import make_task_config + + +def _config(resolved_assets: dict): + return make_task_config(resolved_assets=resolved_assets) + + +def _skill(name: str, content: str, tool_hints: list[str] | None = None) -> dict: + return { + "kind": "skill", + "namespace": "acme", + "name": name, + "version": "1.0.0", + "descriptor": {"summary": "s", "permissions": [], "tool_hints": tool_hints or []}, + "content": content, + "warnings": [], + } + + +class TestRegistrySkillAddendum: + def test_empty_when_no_skills(self): + assert _registry_skill_addendum(_config({})) == "" + assert _registry_skill_addendum(_config({"skills": []})) == "" + + def test_includes_skill_fragment_content(self): + cfg = _config({"skills": [_skill("refactor", "Preserve public APIs when refactoring.")]}) + out = _registry_skill_addendum(cfg) + assert "Preserve public APIs when refactoring." in out + assert "## Skill: acme/refactor" in out + + def test_renders_tool_hints(self): + cfg = _config({"skills": [_skill("refactor", "body", tool_hints=["Edit", "Bash"])]}) + out = _registry_skill_addendum(cfg) + assert "Edit" in out and "Bash" in out + + def test_multiple_skills_each_rendered(self): + cfg = _config({"skills": [_skill("a", "alpha body"), _skill("b", "beta body")]}) + out = _registry_skill_addendum(cfg) + assert "alpha body" in out + assert "beta body" in out + + def test_skips_skill_missing_content(self): + skill = _skill("x", "") + skill["content"] = None + cfg = _config({"skills": [skill]}) + assert _registry_skill_addendum(cfg) == "" diff --git a/agent/tests/test_workflow_validator.py b/agent/tests/test_workflow_validator.py index 85ffa786d..bf24adb83 100644 --- a/agent/tests/test_workflow_validator.py +++ b/agent/tests/test_workflow_validator.py @@ -150,7 +150,7 @@ def test_rule6_read_only_tier_rejects_elevated_fields(self): w["agent_config"]["tier"] = "read-only" w["agent_config"]["allowed_tools"] = ["Bash", "Read"] w["agent_config"]["cedar_policy_modules"] = ["builtin/hard_deny"] - w["agent_config"]["skills"] = ["registry://skill/x-v1"] + w["agent_config"]["skills"] = ["registry://skill/acme/x@^1.0.0"] w["steps"] = [ {"kind": "clone_repo"}, {"kind": "hydrate_context"}, @@ -165,7 +165,17 @@ def test_rule6_read_only_tier_rejects_elevated_fields(self): "ref,bad", [ ("builtin/soft_deny", False), - ("registry://cedar/custom-v1", False), + # #246 grammar (REGISTRY.md ยง6): registry://kind/namespace/name@constraint + ("registry://cedar_policy_module/acme/custom@^1.0.0", False), + ("registry://cedar_policy_module/acme/custom@1.0.0", False), + ("registry://cedar_policy_module/acme/custom@~1.2.3", False), + # legacy 2-segment form is no longer valid grammar (no namespace, no pin) + ("registry://cedar/custom-v1", True), + # unpinned 3-segment is rejected โ€” pins are mandatory (fail-closed) + ("registry://cedar_policy_module/acme/custom", True), + # floating constraints rejected + ("registry://cedar_policy_module/acme/custom@latest", True), + ("registry://cedar_policy_module/acme/custom@>=1.0.0", True), ("http://evil", True), ("soft_deny", True), ], diff --git a/cdk/bootstrap/bootstrap-template.yaml b/cdk/bootstrap/bootstrap-template.yaml index 8026647ce..3894f118b 100644 --- a/cdk/bootstrap/bootstrap-template.yaml +++ b/cdk/bootstrap/bootstrap-template.yaml @@ -1054,6 +1054,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 diff --git a/cdk/bootstrap/policies/application.json b/cdk/bootstrap/policies/application.json index 9f5932d89..7d7aa2d87 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", diff --git a/cdk/package.json b/cdk/package.json index be16744ad..cb851dd84 100644 --- a/cdk/package.json +++ b/cdk/package.json @@ -36,6 +36,7 @@ "constructs": "^10.6.0", "js-yaml": "^4.1.1", "pdf-parse": "^2.4.5", + "semver": "^7.8.5", "ulid": "^3.0.2", "ws": "^8.21.0" }, @@ -49,6 +50,7 @@ "@types/js-yaml": "^4.0.9", "@types/node": "^26", "@types/pdf-parse": "^1.1.5", + "@types/semver": "^7.7.1", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", diff --git a/cdk/src/bootstrap/policies/application.ts b/cdk/src/bootstrap/policies/application.ts index 88b668628..9b8cfbdbd 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', + // Registry (#246) RegistryPublisher/RegistryApprover groups. + 'cognito-idp:CreateGroup', + 'cognito-idp:DeleteGroup', + 'cognito-idp:GetGroup', + 'cognito-idp:UpdateGroup', 'cognito-idp:TagResource', 'cognito-idp:UntagResource', 'cognito-idp:ListTagsForResource', diff --git a/cdk/src/bootstrap/resource-action-map.ts b/cdk/src/bootstrap/resource-action-map.ts index f27394961..fdfc289f0 100644 --- a/cdk/src/bootstrap/resource-action-map.ts +++ b/cdk/src/bootstrap/resource-action-map.ts @@ -63,6 +63,7 @@ export const RESOURCE_ACTION_MAP: Record = { 'AWS::CloudWatch::Dashboard': ['cloudwatch:PutDashboard'], 'AWS::Cognito::UserPool': ['cognito-idp:CreateUserPool'], 'AWS::Cognito::UserPoolClient': ['cognito-idp:CreateUserPoolClient'], + 'AWS::Cognito::UserPoolGroup': ['cognito-idp:CreateGroup'], 'AWS::DynamoDB::Table': ['dynamodb:CreateTable'], 'AWS::EC2::EIP': ['ec2:AllocateAddress'], 'AWS::EC2::FlowLog': ['ec2:CreateFlowLogs'], diff --git a/cdk/src/constructs/blueprint.ts b/cdk/src/constructs/blueprint.ts index 5ac64ac1d..f83ee95d2 100644 --- a/cdk/src/constructs/blueprint.ts +++ b/cdk/src/constructs/blueprint.ts @@ -26,6 +26,10 @@ import { Construct, IValidation } from 'constructs'; // the JSON directly rather than re-using ``handlers/shared/types.ts`` so // the construct layer stays decoupled from runtime-side types. import sharedConstants from '../../../contracts/constants.json'; +// Registry ref grammar (#246). Imported from the dependency-free registry-ref +// module (NOT registry-resolver, which pulls in aws-sdk) so the construct layer +// validates refs at synth without dragging the SDK into the synth graph. +import { isRegistryRef } from '../handlers/shared/registry-ref'; const REPO_PATTERN = /^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/; const DOMAIN_PATTERN = /^(\*\.)?[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/; @@ -149,6 +153,34 @@ export interface BlueprintProps { */ readonly egressAllowlist?: string[]; }; + + /** + * Registry assets (#246) this repo pins. Each entry is a + * ``registry://kind/namespace/name@constraint`` reference resolved at the + * create-task boundary; the pinned versions are stamped on the TaskRecord. + * See docs/design/REGISTRY.md. All three kinds are wired end-to-end. Refs + * are validated at synth (floating constraints are rejected before deploy). + */ + readonly assets?: { + /** + * MCP server registry refs, e.g. ``registry://mcp_server/acme/pdf-tools@^1.4.1``. + * Resolved and merged into the agent's ``.mcp.json`` at task start. + */ + readonly mcpServers?: string[]; + + /** + * Cedar policy module refs, e.g. ``registry://cedar_policy_module/acme/guard@^1.0.0``. + * Resolved text is merged into the agent's Cedar policy set (byte-identical + * to inline ``security.cedarPolicies``). + */ + readonly cedarPolicyModules?: string[]; + + /** + * Skill refs, e.g. ``registry://skill/acme/refactor@^1.0.0``. Resolved + * prompt fragments are appended to the agent's system prompt. + */ + readonly skills?: string[]; + }; } /** @@ -175,6 +207,27 @@ export class Blueprint extends Construct { */ public readonly cedarPolicies: readonly string[]; + /** + * MCP server registry refs from the assets.mcpServers prop (#246), exposed + * for inspection. Flattened into the RepoConfig ``mcp_servers`` column and + * resolved at the create-task boundary. + */ + public readonly mcpServers: readonly string[]; + + /** + * Cedar policy module registry refs from assets.cedarPolicyModules (#246), + * exposed for inspection. Flattened into the RepoConfig ``cedar_policy_modules`` + * column and resolved at the create-task boundary. + */ + public readonly cedarPolicyModules: readonly string[]; + + /** + * Skill registry refs from assets.skills (#246), exposed for inspection. + * Flattened into the RepoConfig ``skills`` column and resolved at the + * create-task boundary. + */ + public readonly skills: readonly string[]; + /** * Cedar HITL: per-task approval-gate cap from the security.approvalGateCap * prop, exposed for inspection. Undefined when the blueprint did not @@ -188,6 +241,9 @@ export class Blueprint extends Construct { this.egressAllowlist = [...(props.networking?.egressAllowlist ?? [])]; this.cedarPolicies = [...(props.security?.cedarPolicies ?? [])]; + this.mcpServers = [...(props.assets?.mcpServers ?? [])]; + this.cedarPolicyModules = [...(props.assets?.cedarPolicyModules ?? [])]; + this.skills = [...(props.assets?.skills ?? [])]; this.approvalGateCap = props.security?.approvalGateCap; // Chunk 7c: emit a synth-time info annotation when the blueprint did @@ -207,6 +263,11 @@ export class Blueprint extends Construct { this.node.addValidation(new RepoFormatValidation(props.repo)); this.node.addValidation(new DomainFormatValidation(this.egressAllowlist)); this.node.addValidation(new ApprovalGateCapValidation(this.approvalGateCap)); + this.node.addValidation(new RegistryRefValidation([ + ...this.mcpServers, + ...this.cedarPolicyModules, + ...this.skills, + ])); const now = new Date().toISOString(); @@ -245,6 +306,15 @@ export class Blueprint extends Construct { if (this.cedarPolicies.length > 0) { item.cedar_policies = { L: this.cedarPolicies.map(p => ({ S: p })) }; } + if (this.mcpServers.length > 0) { + item.mcp_servers = { L: this.mcpServers.map(r => ({ S: r })) }; + } + if (this.cedarPolicyModules.length > 0) { + item.cedar_policy_modules = { L: this.cedarPolicyModules.map(r => ({ S: r })) }; + } + if (this.skills.length > 0) { + item.skills = { L: this.skills.map(r => ({ S: r })) }; + } if (this.approvalGateCap !== undefined) { item.approval_gate_cap = { N: String(this.approvalGateCap) }; } @@ -319,6 +389,9 @@ export class Blueprint extends Construct { if (props.pipeline?.pollIntervalMs !== undefined) fields.push(', #poll_interval_ms = :poll_interval_ms'); if (this.egressAllowlist.length > 0) fields.push(', #egress_allowlist = :egress_allowlist'); if (this.cedarPolicies.length > 0) fields.push(', #cedar_policies = :cedar_policies'); + if (this.mcpServers.length > 0) fields.push(', #mcp_servers = :mcp_servers'); + if (this.cedarPolicyModules.length > 0) fields.push(', #cedar_policy_modules = :cedar_policy_modules'); + if (this.skills.length > 0) fields.push(', #skills = :skills'); if (this.approvalGateCap !== undefined) fields.push(', #approval_gate_cap = :approval_gate_cap'); return fields.join(''); } @@ -334,6 +407,9 @@ export class Blueprint extends Construct { if (props.pipeline?.pollIntervalMs !== undefined) names['#poll_interval_ms'] = 'poll_interval_ms'; if (this.egressAllowlist.length > 0) names['#egress_allowlist'] = 'egress_allowlist'; if (this.cedarPolicies.length > 0) names['#cedar_policies'] = 'cedar_policies'; + if (this.mcpServers.length > 0) names['#mcp_servers'] = 'mcp_servers'; + if (this.cedarPolicyModules.length > 0) names['#cedar_policy_modules'] = 'cedar_policy_modules'; + if (this.skills.length > 0) names['#skills'] = 'skills'; if (this.approvalGateCap !== undefined) names['#approval_gate_cap'] = 'approval_gate_cap'; return names; } @@ -349,6 +425,9 @@ export class Blueprint extends Construct { if (props.pipeline?.pollIntervalMs !== undefined) values[':poll_interval_ms'] = { N: String(props.pipeline.pollIntervalMs) }; if (this.egressAllowlist.length > 0) values[':egress_allowlist'] = { L: this.egressAllowlist.map(d => ({ S: d })) }; if (this.cedarPolicies.length > 0) values[':cedar_policies'] = { L: this.cedarPolicies.map(p => ({ S: p })) }; + if (this.mcpServers.length > 0) values[':mcp_servers'] = { L: this.mcpServers.map(r => ({ S: r })) }; + if (this.cedarPolicyModules.length > 0) values[':cedar_policy_modules'] = { L: this.cedarPolicyModules.map(r => ({ S: r })) }; + if (this.skills.length > 0) values[':skills'] = { L: this.skills.map(r => ({ S: r })) }; if (this.approvalGateCap !== undefined) values[':approval_gate_cap'] = { N: String(this.approvalGateCap) }; return values; } @@ -411,3 +490,24 @@ class ApprovalGateCapValidation implements IValidation { return []; } } + +/** + * Registry (#246) โ€” validates that every assets.* ref matches the + * ``registry://kind/namespace/name@constraint`` grammar (REGISTRY.md ยง6). + * Rejecting a floating or malformed ref at synth means an invalid pin cannot + * deploy and then fail-closed at every task submit (fail-fast vs fail-late). + */ +class RegistryRefValidation implements IValidation { + constructor(private readonly refs: readonly string[]) {} + + public validate(): string[] { + return this.refs + .filter((ref) => !isRegistryRef(ref)) + .map( + (ref) => + `Invalid registry ref: '${ref}'. Expected ` + + '\'registry://kind/namespace/name@constraint\' with a pinned semver ' + + '(exact, ^, or ~) โ€” floating constraints (*, latest, >=) are rejected.', + ); + } +} diff --git a/cdk/src/constructs/registry-artifacts-bucket.ts b/cdk/src/constructs/registry-artifacts-bucket.ts new file mode 100644 index 000000000..c96e4975e --- /dev/null +++ b/cdk/src/constructs/registry-artifacts-bucket.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 { Duration, RemovalPolicy } from 'aws-cdk-lib'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import { Construct } from 'constructs'; + +/** + * How long a noncurrent (superseded) object version is retained before expiry. + * The registry is immutable-per-version, so overwrites at a live key should not + * happen; this rule reaps versions left behind by a ``removed`` tombstone or an + * operational re-put, keeping storage bounded without touching current objects. + */ +export const REGISTRY_ARTIFACT_NONCURRENT_TTL_DAYS = 90; + +/** + * Properties for the RegistryArtifactsBucket construct. + */ +export interface RegistryArtifactsBucketProps { + /** + * Removal policy for the bucket. Registry artifacts are referenced by pinned + * task records for audit/reproducibility, so production should RETAIN; the + * construct default stays DESTROY for the sample stack's dev-first posture. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to auto-delete objects when the bucket is removed (so ``cdk + * destroy`` does not need a manual bucket-empty first). Mirrors the other + * sample-stack buckets. + * @default true + */ + readonly autoDeleteObjects?: boolean; +} + +/** + * S3 bucket for agent asset registry artifact bytes (#246; see + * docs/design/REGISTRY.md ยง3.4 and ADR-018). + * + * Stores the artifact for each published asset version โ€” the MCP server config + * JSON, Cedar policy text, or skill prompt fragment โ€” under the key + * ``{kind}/{namespace}/{name}/{version}/artifact``. Metadata lives in + * ``RegistryAssetsTable``; only the bytes live here. + * + * Differs from the ephemeral ``EcsPayloadBucket``: artifacts are durable + * (referenced by pinned task records for audit and reproducibility), so + * **versioning is ON** and there is no whole-object TTL โ€” only a noncurrent- + * version expiry to bound storage. Immutability of a published + * ``(kind, namespace, name, version)`` is enforced at the DynamoDB write (409 + * on collision), not by S3 object-lock, so a ``removed`` status can tombstone a + * record without a compliance-grade byte delete. + * + * Security / hygiene (parity with the other sample buckets): + * - ``blockPublicAccess: BLOCK_ALL`` + ``enforceSSL: true`` โ€” no public read, + * TLS-only transport. + * - ``encryption: S3_MANAGED`` โ€” server-side encryption at rest. + * - ``versioned: true`` โ€” retain superseded bytes for audit. + */ +export class RegistryArtifactsBucket extends Construct { + /** The underlying S3 bucket. */ + public readonly bucket: s3.Bucket; + + constructor(scope: Construct, id: string, props: RegistryArtifactsBucketProps = {}) { + super(scope, id); + + this.bucket = new s3.Bucket(this, 'Bucket', { + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + encryption: s3.BucketEncryption.S3_MANAGED, + enforceSSL: true, + versioned: true, + lifecycleRules: [ + { + id: 'registry-artifact-noncurrent-expiry', + enabled: true, + noncurrentVersionExpiration: Duration.days(REGISTRY_ARTIFACT_NONCURRENT_TTL_DAYS), + // Reap incomplete multipart uploads after 1 day so stale upload parts + // (which object/version expiry does not cover) do not linger. + abortIncompleteMultipartUploadAfter: Duration.days(1), + }, + ], + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + autoDeleteObjects: props.autoDeleteObjects ?? true, + }); + } +} diff --git a/cdk/src/constructs/registry-assets-table.ts b/cdk/src/constructs/registry-assets-table.ts new file mode 100644 index 000000000..2310026fd --- /dev/null +++ b/cdk/src/constructs/registry-assets-table.ts @@ -0,0 +1,113 @@ +/** + * 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 { RemovalPolicy } from 'aws-cdk-lib'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import { Construct } from 'constructs'; + +/** GSI name for listing every asset of a given kind (REGISTRY.md ยง3.1). */ +export const REGISTRY_KIND_INDEX = 'kind-index'; + +/** + * Properties for the RegistryAssetsTable construct. + */ +export interface RegistryAssetsTableProps { + /** + * Optional table name override. + * @default - auto-generated by CloudFormation + */ + readonly tableName?: string; + + /** + * Removal policy for the table. Registry records are the source of truth for + * what a task may load and are immutable-per-version, so production + * deployments should RETAIN; the construct default stays DESTROY to match the + * rest of the sample stack's dev-first posture. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to enable point-in-time recovery. + * @default true + */ + readonly pointInTimeRecovery?: boolean; +} + +/** + * DynamoDB table backing the agent asset registry (#246; see + * docs/design/REGISTRY.md ยง3.1 and ADR-018). + * + * Schema: + * - ``pk`` (partition) = ``{kind}#{namespace}/{name}`` โ€” groups every version + * of one asset under a single partition. + * - ``sk`` (sort) = the semver ``version`` string. + * + * A single ``Query`` on ``pk`` returns all versions of an asset (show + resolve; + * resolution ranks by parsed semver in code, since DynamoDB sorts the version + * sort key lexicographically). The ``kind-index`` GSI (partition ``kind``, sort + * ``pk``) powers ``GET /registry/assets?kind=...`` without a table scan. + * + * There is no TTL: registry records are immutable-per-version and audited; a + * ``removed`` status tombstones a record rather than deleting the row (a task + * pinned to it fails closed with ``REMOVED``). + */ +export class RegistryAssetsTable extends Construct { + /** + * The underlying DynamoDB table. Use this to grant access or read the table name. + */ + public readonly table: dynamodb.Table; + + constructor(scope: Construct, id: string, props: RegistryAssetsTableProps = {}) { + super(scope, id); + + this.table = new dynamodb.Table(this, 'Table', { + tableName: props.tableName, + partitionKey: { + name: 'pk', + type: dynamodb.AttributeType.STRING, + }, + sortKey: { + name: 'sk', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true, + }, + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + }); + + this.table.addGlobalSecondaryIndex({ + indexName: REGISTRY_KIND_INDEX, + partitionKey: { + name: 'kind', + type: dynamodb.AttributeType.STRING, + }, + sortKey: { + name: 'pk', + type: dynamodb.AttributeType.STRING, + }, + // The list endpoint needs kind, namespace, name, version, and status to + // render a summary โ€” a KEYS_ONLY projection would force a second read per + // row, so project everything. + projectionType: dynamodb.ProjectionType.ALL, + }); + } +} diff --git a/cdk/src/constructs/task-api.ts b/cdk/src/constructs/task-api.ts index 1c5ad7f4d..c0a49b5a7 100644 --- a/cdk/src/constructs/task-api.ts +++ b/cdk/src/constructs/task-api.ts @@ -186,6 +186,20 @@ export interface TaskApiProps { * Required when attachmentsBucket is provided. */ readonly userConcurrencyTable?: dynamodb.ITable; + + /** + * Agent asset registry table (#246). When provided together with + * {@link registryArtifactsBucket}, the four ``/registry`` routes + * (publish/resolve/list/show) are created. See docs/design/REGISTRY.md. + */ + readonly registryAssetsTable?: dynamodb.ITable; + + /** + * Agent asset registry artifact bucket (#246). Publish writes artifact bytes + * here; resolve issues short-lived presigned GETs. Paired with + * {@link registryAssetsTable}. + */ + readonly registryArtifactsBucket?: s3.IBucket; } /** @@ -1167,6 +1181,106 @@ export class TaskApi extends Construct { allFunctions.push(createWebhookFn, listWebhooksFn, deleteWebhookFn, webhookAuthorizerFn, webhookCreateTaskFn); } + // --- Agent asset registry endpoints (#246) --- + // Created only when both storage handles are provided. Publish/resolve/ + // list/show over the shared Cognito authorizer; publish additionally gates + // on the RegistryPublisher/RegistryApprover groups inside the handler. + if (props.registryAssetsTable && props.registryArtifactsBucket) { + // Cognito groups the publish handler gates on (REGISTRY.md ยง10). The + // handler reads ``cognito:groups`` from the JWT; these resources make the + // group names assignable via ``admin-add-user-to-group``. Publisher can + // create records (land ``submitted``); Approver can also approve and + // auto-approve. Group *names* must match the handler constants + // (registry-publish.ts PUBLISHER_GROUP / APPROVER_GROUP). + new cognito.CfnUserPoolGroup(this, 'RegistryPublisherGroup', { + userPoolId: this.userPool.userPoolId, + groupName: 'RegistryPublisher', + description: 'May publish registry assets (records land in submitted state).', + }); + new cognito.CfnUserPoolGroup(this, 'RegistryApproverGroup', { + userPoolId: this.userPool.userPoolId, + groupName: 'RegistryApprover', + description: 'May approve/reject/deprecate registry assets and auto-approve on publish.', + }); + + const registryEnv = { + REGISTRY_ASSETS_TABLE_NAME: props.registryAssetsTable.tableName, + REGISTRY_ARTIFACTS_BUCKET_NAME: props.registryArtifactsBucket.bucketName, + }; + + // memorySize is explicit (API_HANDLER_MEMORY_MB): the esbuild bundles + // for these handlers exceed the 128 MB Lambda default at init (the list + // handler in particular), so an omitted memorySize OOMs on cold start + // (Runtime.OutOfMemory โ†’ 502). Matches the other API handlers. + const registryPublishFn = new lambda.NodejsFunction(this, 'RegistryPublishFn', { + entry: path.join(handlersDir, 'registry-publish.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + environment: registryEnv, + bundling: commonBundling, + memorySize: API_HANDLER_MEMORY_MB, + }); + + const registryResolveFn = new lambda.NodejsFunction(this, 'RegistryResolveFn', { + entry: path.join(handlersDir, 'registry-resolve.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + environment: registryEnv, + bundling: commonBundling, + memorySize: API_HANDLER_MEMORY_MB, + }); + + const registryListFn = new lambda.NodejsFunction(this, 'RegistryListFn', { + entry: path.join(handlersDir, 'registry-list.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + environment: registryEnv, + bundling: commonBundling, + memorySize: API_HANDLER_MEMORY_MB, + }); + + const registryShowFn = new lambda.NodejsFunction(this, 'RegistryShowFn', { + entry: path.join(handlersDir, 'registry-show.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + environment: registryEnv, + bundling: commonBundling, + memorySize: API_HANDLER_MEMORY_MB, + }); + + // IAM: publish read/writes the table + writes artifacts; resolve/list/ + // show are read-only (least privilege, REGISTRY.md ยง11 grants table). + props.registryAssetsTable.grantReadWriteData(registryPublishFn); + props.registryArtifactsBucket.grantPut(registryPublishFn); + props.registryAssetsTable.grantReadData(registryResolveFn); + props.registryArtifactsBucket.grantRead(registryResolveFn); + props.registryAssetsTable.grantReadData(registryListFn); + props.registryAssetsTable.grantReadData(registryShowFn); + + // --- API resource tree: /registry --- + const registry = this.api.root.addResource('registry'); + + const registryAssets = registry.addResource('assets'); + registryAssets.addMethod('POST', new apigw.LambdaIntegration(registryPublishFn), cognitoAuthOptions); + registryAssets.addMethod('GET', new apigw.LambdaIntegration(registryListFn), cognitoAuthOptions); + + // /registry/assets/{kind}/{namespace}/{name} โ€” show all versions + const registryShowResource = registryAssets + .addResource('{kind}') + .addResource('{namespace}') + .addResource('{name}'); + registryShowResource.addMethod('GET', new apigw.LambdaIntegration(registryShowFn), cognitoAuthOptions); + + const registryResolve = registry.addResource('resolve'); + registryResolve.addMethod('GET', new apigw.LambdaIntegration(registryResolveFn), cognitoAuthOptions); + + allFunctions.push(registryPublishFn, registryResolveFn, registryListFn, registryShowFn); + } + // --- cdk-nag suppressions for CDK-generated IAM policies --- for (const fn of allFunctions) { NagSuppressions.addResourceSuppressions(fn, [ diff --git a/cdk/src/constructs/task-orchestrator.ts b/cdk/src/constructs/task-orchestrator.ts index a638b369f..f282f5582 100644 --- a/cdk/src/constructs/task-orchestrator.ts +++ b/cdk/src/constructs/task-orchestrator.ts @@ -81,6 +81,14 @@ export interface TaskOrchestratorProps { */ readonly repoTable?: dynamodb.ITable; + /** + * The registry assets table (#246). When provided, the orchestrator's + * resolve-registry step (run during hydration) can resolve the blueprint's + * ``registry://`` pins. Grants read access + sets REGISTRY_ASSETS_TABLE_NAME. + * Without it, a blueprint pinning any asset fails closed at task start. + */ + readonly registryAssetsTable?: dynamodb.ITable; + /** * Maximum concurrent tasks per user. * @@ -259,6 +267,7 @@ export class TaskOrchestrator extends Construct { MAX_CONCURRENT_TASKS_PER_USER: String(maxConcurrent), TASK_RETENTION_DAYS: String(props.taskRetentionDays ?? DEFAULT_TASK_RETENTION_DAYS), ...(props.repoTable && { REPO_TABLE_NAME: props.repoTable.tableName }), + ...(props.registryAssetsTable && { REGISTRY_ASSETS_TABLE_NAME: props.registryAssetsTable.tableName }), ...(props.githubTokenSecretArn && { GITHUB_TOKEN_SECRET_ARN: props.githubTokenSecretArn }), ...(props.userPromptTokenBudget !== undefined && { USER_PROMPT_TOKEN_BUDGET: String(props.userPromptTokenBudget), @@ -288,6 +297,9 @@ export class TaskOrchestrator extends Construct { if (props.repoTable) { props.repoTable.grantReadData(this.fn); } + if (props.registryAssetsTable) { + props.registryAssetsTable.grantReadData(this.fn); + } // Attachments bucket grants (URL fetch/screen/upload during hydration) if (props.attachmentsBucket) { diff --git a/cdk/src/handlers/registry-list.ts b/cdk/src/handlers/registry-list.ts new file mode 100644 index 000000000..c0c158a5f --- /dev/null +++ b/cdk/src/handlers/registry-list.ts @@ -0,0 +1,98 @@ +/** + * 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 { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import * as semver from 'semver'; +import { ulid } from 'ulid'; +import { extractUserId } from './shared/gateway'; +import { logger } from './shared/logger'; +import { REGISTRY_KIND_INDEX } from '../constructs/registry-assets-table'; +import { PUBLISHABLE_KINDS, RESERVED_KINDS } from './shared/registry-descriptor'; +import { ErrorCode, errorResponse, successResponse } from './shared/response'; +import type { RegistryAssetKind, RegistryAssetRecord } from './shared/types'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const TABLE_NAME = process.env.REGISTRY_ASSETS_TABLE_NAME!; + +/** + * GET /v1/registry/assets?kind=mcp_server โ€” list assets of a kind + * (REGISTRY.md ยง4.3). Queries the ``kind-index`` GSI, collapses versions to + * one row per asset (highest approved/deprecated version), and excludes + * ``removed`` unless ``?status=removed`` is requested. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + const requestId = ulid(); + + try { + if (!extractUserId(event)) { + return errorResponse(401, ErrorCode.UNAUTHORIZED, 'Missing or invalid authentication.', requestId); + } + + const kind = event.queryStringParameters?.kind; + if (!kind || (!PUBLISHABLE_KINDS.has(kind as RegistryAssetKind) && !RESERVED_KINDS.has(kind as RegistryAssetKind))) { + return errorResponse( + 400, + ErrorCode.VALIDATION_ERROR, + 'Query parameter "kind" is required and must be a known asset kind.', + requestId, + ); + } + const namespaceFilter = event.queryStringParameters?.namespace; + + const result = await ddb.send( + new QueryCommand({ + TableName: TABLE_NAME, + IndexName: REGISTRY_KIND_INDEX, + KeyConditionExpression: 'kind = :kind', + ExpressionAttributeValues: { ':kind': kind }, + }), + ); + const rows = (result.Items ?? []) as unknown as RegistryAssetRecord[]; + + // Collapse to one summary per (namespace/name), keeping the highest semver + // version and its status. Exclude removed rows unless explicitly requested. + const includeRemoved = event.queryStringParameters?.status === 'removed'; + const byAsset = new Map(); + for (const row of rows) { + if (namespaceFilter && row.namespace !== namespaceFilter) continue; + if (row.status === 'removed' && !includeRemoved) continue; + const key = row.pk; + const current = byAsset.get(key); + if (!current || semver.gt(row.version, current.version)) { + byAsset.set(key, row); + } + } + + const assets = [...byAsset.values()].map((r) => ({ + kind: r.kind, + namespace: r.namespace, + name: r.name, + latest_version: r.version, + status: r.status, + })); + + logger.info('registry list', { kind, count: assets.length, request_id: requestId }); + return successResponse(200, { assets }, requestId); + } catch (err) { + logger.error('registry list failed', { error: String(err), request_id: requestId }); + return errorResponse(500, ErrorCode.INTERNAL_ERROR, 'Internal server error.', requestId); + } +} diff --git a/cdk/src/handlers/registry-publish.ts b/cdk/src/handlers/registry-publish.ts new file mode 100644 index 000000000..6fa22780c --- /dev/null +++ b/cdk/src/handlers/registry-publish.ts @@ -0,0 +1,193 @@ +/** + * 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 { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { ulid } from 'ulid'; +import { extractGroups, extractUserId } from './shared/gateway'; +import { logger } from './shared/logger'; +import { + artifactKey, + asDescriptor, + publishPk, + validatePublish, + type PublishInput, +} from './shared/registry-descriptor'; +import { ErrorCode, errorResponse, successResponse } from './shared/response'; +import type { RegistryAssetKind, RegistryAssetRecord, RegistryAssetStatus } from './shared/types'; +import { parseBody } from './shared/validation'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const s3 = new S3Client({}); +const TABLE_NAME = process.env.REGISTRY_ASSETS_TABLE_NAME!; +const BUCKET_NAME = process.env.REGISTRY_ARTIFACTS_BUCKET_NAME!; + +/** Cognito group that may create records (lands in ``submitted``). */ +export const PUBLISHER_GROUP = 'RegistryPublisher'; +/** Cognito group that may approve/reject/deprecate and auto-approve on publish. */ +export const APPROVER_GROUP = 'RegistryApprover'; + +/** + * POST /v1/registry/assets โ€” publish a new asset version (REGISTRY.md ยง4.1). + * + * Auth: caller must be in ``RegistryPublisher`` (or ``RegistryApprover``). + * Record lands ``submitted`` unless the caller is an approver and passes + * ``?auto_approve=true`` (dev), in which case it lands ``approved``. + * Immutability: 409 on ``(kind, namespace, name, version)`` collision. + */ +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 groups = extractGroups(event); + const isPublisher = groups.includes(PUBLISHER_GROUP); + const isApprover = groups.includes(APPROVER_GROUP); + if (!isPublisher && !isApprover) { + return errorResponse( + 403, + ErrorCode.FORBIDDEN, + `Publishing requires membership in the ${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 violations = validatePublish(body); + if (violations.length > 0) { + return errorResponse( + 400, + ErrorCode.VALIDATION_ERROR, + `Invalid publish request: ${violations.map((x) => `${x.field}: ${x.message}`).join('; ')}`, + requestId, + ); + } + + // Post-validation, the identity fields are known-good strings. + const kind = body.kind as RegistryAssetKind; + const namespace = body.namespace as string; + const name = body.name as string; + const version = body.version as string; + const pk = publishPk(kind, namespace, name); + + // Auto-approve is a dev convenience gated on approver rights (REGISTRY.md ยง10). + const autoApprove = event.queryStringParameters?.auto_approve === 'true' && isApprover; + const status: RegistryAssetStatus = autoApprove ? 'approved' : 'submitted'; + const now = new Date().toISOString(); + + // 1. Upload artifact ONLY when the request actually carries bytes. Kinds + // with inline descriptor content (mcp_server server_config, cedar_text, + // prompt_fragment) supply no artifact_b64 โ€” validation already accepted + // that (see hasInlineDescriptorContent), so uploading here would call + // Buffer.from(undefined) and 500. Keyed by the immutable tuple; the + // bucket is versioned so a re-put is recoverable. + let artifactRef: string | undefined; + if (typeof body.artifact_b64 === 'string' && body.artifact_b64.length > 0) { + artifactRef = artifactKey(kind, namespace, name, version); + await s3.send( + new PutObjectCommand({ + Bucket: BUCKET_NAME, + Key: artifactRef, + Body: Buffer.from(body.artifact_b64, 'base64'), + }), + ); + } + + // 2. Write the record with an immutability guard. + const record: RegistryAssetRecord = { + pk, + sk: version, + kind, + namespace, + name, + version, + descriptor: asDescriptor(body.descriptor), + artifact_ref: artifactRef, + status, + publisher: userId, + created_at: now, + status_history: [{ status, actor: userId, at: now }], + }; + + try { + await ddb.send( + new PutCommand({ + TableName: TABLE_NAME, + Item: record, + ConditionExpression: 'attribute_not_exists(pk) AND attribute_not_exists(sk)', + }), + ); + } catch (err) { + if (isConditionalCheckFailed(err)) { + return errorResponse( + 409, + ErrorCode.REGISTRY_VERSION_EXISTS, + `${kind}/${namespace}/${name}@${version} already exists; publish a new version instead.`, + requestId, + ); + } + throw err; + } + + logger.info('registry asset published', { + pk, + version, + status, + publisher: userId, + request_id: requestId, + }); + + return successResponse( + 201, + { + kind, + namespace, + name, + version, + status, + artifact_ref: artifactRef, + created_at: now, + }, + requestId, + ); + } catch (err) { + logger.error('registry publish failed', { error: String(err), request_id: requestId }); + return errorResponse(500, ErrorCode.INTERNAL_ERROR, 'Internal server error.', requestId); + } +} + +/** True when a DDB error is a ConditionalCheckFailedException (immutability hit). */ +function isConditionalCheckFailed(err: unknown): boolean { + return ( + typeof err === 'object' && + err !== null && + 'name' in err && + (err as { name: string }).name === 'ConditionalCheckFailedException' + ); +} diff --git a/cdk/src/handlers/registry-resolve.ts b/cdk/src/handlers/registry-resolve.ts new file mode 100644 index 000000000..a068dae04 --- /dev/null +++ b/cdk/src/handlers/registry-resolve.ts @@ -0,0 +1,103 @@ +/** + * 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 { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { ulid } from 'ulid'; +import { extractUserId } from './shared/gateway'; +import { logger } from './shared/logger'; +import { artifactKey, KINDS_REQUIRING_ARTIFACT } from './shared/registry-descriptor'; +import { + parseRef, + RegistryResolutionError, + resolveRef, +} from './shared/registry-resolver'; +import { ErrorCode, errorResponse, successResponse } from './shared/response'; + +const s3 = new S3Client({}); +const BUCKET_NAME = process.env.REGISTRY_ARTIFACTS_BUCKET_NAME!; + +/** Presigned artifact URL lifetime (seconds). */ +const ARTIFACT_URL_TTL_SECONDS = 300; + +/** + * GET /v1/registry/resolve?ref=registry://... โ€” resolve a ref to a pinned + * asset (REGISTRY.md ยง4.2). Returns the descriptor, the concrete version, a + * short-lived presigned artifact URL (for kinds with an artifact), and any + * warnings. Fails 422 with a specific reason on any unresolved ref. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + const requestId = ulid(); + + try { + // Resolve/read is available to any authenticated caller (REGISTRY.md ยง10). + if (!extractUserId(event)) { + return errorResponse(401, ErrorCode.UNAUTHORIZED, 'Missing or invalid authentication.', requestId); + } + + const ref = event.queryStringParameters?.ref; + if (!ref) { + return errorResponse(400, ErrorCode.VALIDATION_ERROR, 'Query parameter "ref" is required.', requestId); + } + + let resolved; + try { + resolved = await resolveRef(ref); + } catch (err) { + if (err instanceof RegistryResolutionError) { + return errorResponse( + 422, + ErrorCode.REGISTRY_RESOLUTION_FAILED, + `Could not resolve ${ref}: ${err.reason}.`, + requestId, + ); + } + throw err; + } + + // Presign the artifact for kinds that have one, so a caller can fetch bytes + // directly without a second round-trip. + let artifactUrl: string | undefined; + if (KINDS_REQUIRING_ARTIFACT.has(resolved.kind)) { + const key = artifactKey(resolved.kind, resolved.namespace, resolved.name, resolved.version); + artifactUrl = await getSignedUrl( + s3, + new GetObjectCommand({ Bucket: BUCKET_NAME, Key: key }), + { expiresIn: ARTIFACT_URL_TTL_SECONDS }, + ); + } + + logger.info('registry ref resolved via API', { + ref, + version: resolved.version, + warnings: resolved.warnings, + request_id: requestId, + }); + + return successResponse(200, { ...resolved, artifact_url: artifactUrl }, requestId); + } catch (err) { + logger.error('registry resolve failed', { error: String(err), request_id: requestId }); + return errorResponse(500, ErrorCode.INTERNAL_ERROR, 'Internal server error.', requestId); + } +} + +// parseRef is imported to keep the grammar entry point discoverable alongside +// the handler; the resolver uses it internally. Re-export for callers/tests. +export { parseRef }; diff --git a/cdk/src/handlers/registry-show.ts b/cdk/src/handlers/registry-show.ts new file mode 100644 index 000000000..21abcc728 --- /dev/null +++ b/cdk/src/handlers/registry-show.ts @@ -0,0 +1,91 @@ +/** + * 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 { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, QueryCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import * as semver from 'semver'; +import { ulid } from 'ulid'; +import { extractUserId } from './shared/gateway'; +import { logger } from './shared/logger'; +import { publishPk, PUBLISHABLE_KINDS, RESERVED_KINDS } from './shared/registry-descriptor'; +import { ErrorCode, errorResponse, successResponse } from './shared/response'; +import type { RegistryAssetKind, RegistryAssetRecord } from './shared/types'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const TABLE_NAME = process.env.REGISTRY_ASSETS_TABLE_NAME!; + +/** + * GET /v1/registry/assets/{kind}/{namespace}/{name} โ€” show every version of a + * single asset (REGISTRY.md ยง4.4). Single Query on the partition; versions are + * returned newest-semver-first. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + const requestId = ulid(); + + try { + if (!extractUserId(event)) { + 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, 'Path must be /registry/assets/{kind}/{namespace}/{name}.', requestId); + } + if (!PUBLISHABLE_KINDS.has(kind as RegistryAssetKind) && !RESERVED_KINDS.has(kind as RegistryAssetKind)) { + return errorResponse(400, ErrorCode.VALIDATION_ERROR, `Unknown asset kind ${kind}.`, requestId); + } + + const result = await ddb.send( + new QueryCommand({ + TableName: TABLE_NAME, + KeyConditionExpression: 'pk = :pk', + ExpressionAttributeValues: { ':pk': publishPk(kind, namespace, name) }, + }), + ); + const rows = (result.Items ?? []) as unknown as RegistryAssetRecord[]; + + if (rows.length === 0) { + return errorResponse( + 404, + ErrorCode.REGISTRY_ASSET_NOT_FOUND, + `No asset ${kind}/${namespace}/${name}.`, + requestId, + ); + } + + const versions = rows + .slice() + .sort((a, b) => semver.rcompare(a.version, b.version)) + .map((r) => ({ + version: r.version, + status: r.status, + created_at: r.created_at, + publisher: r.publisher, + })); + + logger.info('registry show', { kind, namespace, name, versions: versions.length, request_id: requestId }); + return successResponse(200, { kind, namespace, name, versions }, requestId); + } catch (err) { + logger.error('registry show failed', { error: String(err), request_id: requestId }); + return errorResponse(500, ErrorCode.INTERNAL_ERROR, 'Internal server error.', requestId); + } +} diff --git a/cdk/src/handlers/shared/gateway.ts b/cdk/src/handlers/shared/gateway.ts index efcfb4e31..0a51e26d6 100644 --- a/cdk/src/handlers/shared/gateway.ts +++ b/cdk/src/handlers/shared/gateway.ts @@ -31,6 +31,30 @@ export function extractUserId(event: APIGatewayProxyEvent): string | null { return claims.sub; } +/** + * Extract the caller's Cognito group memberships from the authorizer claims. + * Cognito places groups in the ``cognito:groups`` claim, which API Gateway may + * surface either as a JSON-ish array or a comma/space-separated string + * depending on the integration โ€” this normalizes both to a string array. + * + * Used by the registry (#246) publish/promote endpoints to gate on + * ``RegistryPublisher`` / ``RegistryApprover`` (REGISTRY.md ยง10). + * @param event - the API Gateway proxy event. + * @returns the caller's group names (empty array when none / unauthenticated). + */ +export function extractGroups(event: APIGatewayProxyEvent): string[] { + const raw = event.requestContext.authorizer?.claims?.['cognito:groups']; + if (!raw) return []; + if (Array.isArray(raw)) return raw.map(String); + // API Gateway commonly stringifies the list, e.g. "[Publisher Approver]" or + // "Publisher,Approver". Strip brackets, then split on comma or whitespace. + return String(raw) + .replace(/^\[|\]$/g, '') + .split(/[,\s]+/) + .map((g) => g.trim()) + .filter((g) => g.length > 0); +} + /** * Generate a branch name from task ID and description. * Pattern: `bgagent/{taskId}/{slug}` diff --git a/cdk/src/handlers/shared/orchestrator.ts b/cdk/src/handlers/shared/orchestrator.ts index 7308c4532..d62c73c20 100644 --- a/cdk/src/handlers/shared/orchestrator.ts +++ b/cdk/src/handlers/shared/orchestrator.ts @@ -26,9 +26,10 @@ import { logger, type Logger } from './logger'; import { writeMinimalEpisode } from './memory'; import { coerceNumericOrNull } from './numeric'; import { computePromptVersion } from './prompt-version'; +import { RegistryResolutionError, resolveAll } from './registry-resolver'; import { loadRepoConfig, type BlueprintConfig, type ComputeType } from './repo-config'; import { resolveUrlAttachments } from './resolve-url-attachments'; -import { APPROVAL_GATE_CAP_MAX, APPROVAL_GATE_CAP_MIN, type AgentAttachmentPayload, type AttachmentRecord, type TaskRecord } from './types'; +import { APPROVAL_GATE_CAP_MAX, APPROVAL_GATE_CAP_MIN, type AgentAttachmentPayload, type AttachmentRecord, type ResolvedAssetBundle, type ResolvedAssetSummary, type TaskRecord } from './types'; import { computeTtlEpoch, DEFAULT_MAX_TURNS } from './validation'; import { TaskStatus, TERMINAL_STATUSES, VALID_TRANSITIONS, type TaskStatusType } from '../../constructs/task-status'; @@ -278,6 +279,9 @@ export async function loadBlueprintConfig(task: TaskRecord): Promise { + const { log, correlation } = envelopeFor(task); + // Collect every asset-kind's pins; resolveAll routes each ref to the right + // bundle slot by its parsed kind (#246 PR 3 adds cedar_policy_modules + skills + // alongside PR 2's mcp_servers). + const refs = [ + ...(blueprintConfig?.mcp_servers ?? []), + ...(blueprintConfig?.cedar_policy_modules ?? []), + ...(blueprintConfig?.skills ?? []), + ]; + if (refs.length === 0) { + return EMPTY_ASSET_BUNDLE; + } + + let bundle: ResolvedAssetBundle; + try { + bundle = await resolveAll(refs); + } catch (err) { + if (err instanceof RegistryResolutionError) { + // Surface the specific reason on an audit event before the caller fails + // the task, so an operator can see WHICH pin broke and why. + await emitTaskEvent(task.task_id, 'registry_resolution_failed', { + ref: err.ref, + reason: err.reason, + }, correlation).catch((eventErr) => { + log.error('Failed to emit registry_resolution_failed event', { + error: eventErr instanceof Error ? eventErr.message : String(eventErr), + }); + }); + } + throw err; + } + + // Flatten to {kind, id, version} audit triples and persist on the TaskRecord. + const summaries: ResolvedAssetSummary[] = [ + ...bundle.mcp_servers, + ...bundle.cedar_policy_modules, + ...bundle.skills, + ].map((a) => ({ kind: a.kind, id: `${a.namespace}/${a.name}`, version: a.version })); + + // Warn on any deprecated resolution so it is visible in the task timeline. + const deprecated = [...bundle.mcp_servers, ...bundle.cedar_policy_modules, ...bundle.skills] + .filter((a) => a.warnings.includes('DEPRECATED')) + .map((a) => `${a.kind}/${a.namespace}/${a.name}@${a.version}`); + + try { + await ddb.send(new UpdateCommand({ + TableName: TABLE_NAME, + Key: { task_id: task.task_id }, + UpdateExpression: 'SET #ra = :ra, #ua = :now', + ExpressionAttributeNames: { '#ra': 'resolved_assets', '#ua': 'updated_at' }, + ExpressionAttributeValues: { ':ra': summaries, ':now': new Date().toISOString() }, + })); + } catch (err) { + // The stamp is for audit; a write failure must not silently drop it, but it + // also should not fail an otherwise-resolvable task. Log loudly. + log.error('Failed to stamp resolved_assets on task record', { + error: err instanceof Error ? err.message : String(err), + }); + } + + await emitTaskEvent(task.task_id, 'registry_assets_resolved', { + resolved_assets: summaries, + ...(deprecated.length > 0 && { deprecated }), + }, correlation).catch(() => undefined); + + return bundle; +} + /** * Transition task to HYDRATING and assemble the invocation payload. * @param task - the task record. @@ -364,6 +469,26 @@ export async function hydrateAndTransition(task: TaskRecord, blueprintConfig?: B await transitionTask(task.task_id, TaskStatus.SUBMITTED, TaskStatus.HYDRATING); await emitTaskEvent(task.task_id, 'hydration_started', undefined, correlation); + // Resolve registry assets (#246) BEFORE hydration so a bad pin fails fast โ€” + // no point paying for context hydration if the asset surface can't resolve. + // Fail-closed: a RegistryResolutionError propagates to the caller, which + // transitions the task to FAILED (same path as a guardrail block below). + const resolvedAssets = await resolveRegistryAssetsForTask(task, blueprintConfig); + + // Merge registry cedar_policy_module content into the same cedar_policies + // list the blueprint's inline policies use, so the agent's PolicyEngine sees + // one combined set โ€” registry-sourced Cedar is byte-identical to inline + // (REGISTRY.md ยง8; the parity guarantee holds by construction because both + // arrive through the same payload field). Inline policies come first, then + // resolved modules in bundle order. + const registryCedarPolicies = resolvedAssets.cedar_policy_modules + .map((a) => a.content) + .filter((text): text is string => typeof text === 'string' && text.length > 0); + const mergedCedarPolicies = [ + ...(blueprintConfig?.cedar_policies ?? []), + ...registryCedarPolicies, + ]; + const hydratedContext = await hydrateContext(task, { githubTokenSecretArn: blueprintConfig?.github_token_secret_arn, memoryId: MEMORY_ID, @@ -548,7 +673,14 @@ export async function hydrateAndTransition(task: TaskRecord, blueprintConfig?: B ...(task.trace === true && { trace: true }), ...(blueprintConfig?.model_id && { model_id: blueprintConfig.model_id }), ...(blueprintConfig?.system_prompt_overrides && { system_prompt_overrides: blueprintConfig.system_prompt_overrides }), - ...(blueprintConfig?.cedar_policies && blueprintConfig.cedar_policies.length > 0 && { cedar_policies: blueprintConfig.cedar_policies }), + ...(mergedCedarPolicies.length > 0 && { cedar_policies: mergedCedarPolicies }), + // Registry (#246): the resolved asset bundle. Only threaded when the + // blueprint pinned at least one asset, so the common no-registry path keeps + // the payload slim and the agent's ``inp.get("resolved_assets")`` stays + // absent. The agent-side loader (registry_loader.py) applies each kind. + ...((resolvedAssets.mcp_servers.length > 0 + || resolvedAssets.cedar_policy_modules.length > 0 + || resolvedAssets.skills.length > 0) && { resolved_assets: resolvedAssets }), // Cedar HITL: the agent's PreToolUse hook uses this to compute // the maxLifetime ceiling on per-gate approval timeouts (ยง6.5). // Stamped at HYDRATING โ†’ RUNNING transition time so the clock diff --git a/cdk/src/handlers/shared/registry-descriptor.ts b/cdk/src/handlers/shared/registry-descriptor.ts new file mode 100644 index 000000000..722f01b85 --- /dev/null +++ b/cdk/src/handlers/shared/registry-descriptor.ts @@ -0,0 +1,226 @@ +/** + * 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. + */ + +/** + * Publish-time validation for registry assets (#246; REGISTRY.md ยง2/ยง3.3/ยง5/ยง6). + * Pure functions with no AWS dependency so they are unit-testable in isolation + * and reused by the publish handler. + */ + +import * as semver from 'semver'; +import type { RegistryAssetKind, RegistryDescriptor } from './types'; + +/** Kinds that have a loader in MVP and may therefore be published. */ +export const PUBLISHABLE_KINDS: ReadonlySet = new Set([ + 'mcp_server', + 'cedar_policy_module', + 'skill', +]); + +/** Kinds carried in the grammar but not yet loadable (publish is rejected). */ +export const RESERVED_KINDS: ReadonlySet = new Set([ + 'plugin', + 'subagent', + 'prompt_fragment', + 'capability', +]); + +const NAMESPACE_RE = /^[a-z][a-z0-9-]*$/; +const NAME_RE = /^[a-z0-9][a-z0-9._-]*$/; + +/** Kinds whose artifact bytes are required at publish (REGISTRY.md ยง3.3). */ +export const KINDS_REQUIRING_ARTIFACT: ReadonlySet = new Set([ + 'mcp_server', + 'cedar_policy_module', + 'skill', +]); + +/** A single validation failure: a machine field key + human message. */ +export interface DescriptorViolation { + readonly field: string; + readonly message: string; +} + +export interface PublishInput { + readonly kind?: unknown; + readonly namespace?: unknown; + readonly name?: unknown; + readonly version?: unknown; + readonly descriptor?: unknown; + readonly artifact_b64?: unknown; +} + +/** + * Validate a publish request's identity fields, version, and per-kind + * descriptor. Returns all violations found (empty == valid) so the caller can + * surface them together rather than one at a time. + */ +export function validatePublish(input: PublishInput): DescriptorViolation[] { + const v: DescriptorViolation[] = []; + + // --- kind --- + const kind = input.kind; + if (typeof kind !== 'string' || (!PUBLISHABLE_KINDS.has(kind as RegistryAssetKind) && !RESERVED_KINDS.has(kind as RegistryAssetKind))) { + v.push({ field: 'kind', message: `unknown kind ${String(kind)}` }); + } else if (RESERVED_KINDS.has(kind as RegistryAssetKind)) { + v.push({ field: 'kind', message: `kind ${kind} is reserved and has no loader in MVP; cannot be published` }); + } + + // --- namespace / name --- + if (typeof input.namespace !== 'string' || !NAMESPACE_RE.test(input.namespace)) { + v.push({ field: 'namespace', message: 'namespace must match ^[a-z][a-z0-9-]*$' }); + } + if (typeof input.name !== 'string' || !NAME_RE.test(input.name)) { + v.push({ field: 'name', message: 'name must match ^[a-z0-9][a-z0-9._-]*$' }); + } + + // --- version: must be an EXACT semver (not a range) --- + if (typeof input.version !== 'string' || semver.valid(input.version) === null) { + v.push({ field: 'version', message: 'version must be an exact semver (e.g. 1.4.1)' }); + } + + // --- descriptor: shared + per-kind required fields --- + const d = input.descriptor; + if (typeof d !== 'object' || d === null) { + v.push({ field: 'descriptor', message: 'descriptor is required and must be an object' }); + } else { + const desc = d as Record; + if (typeof desc.summary !== 'string' || desc.summary.length === 0) { + v.push({ field: 'descriptor.summary', message: 'summary is required' }); + } + if (!Array.isArray(desc.permissions)) { + v.push({ field: 'descriptor.permissions', message: 'permissions must be an array' }); + } + if (typeof kind === 'string' && PUBLISHABLE_KINDS.has(kind as RegistryAssetKind)) { + v.push(...validateKindDescriptor(kind as RegistryAssetKind, desc)); + } + } + + // --- artifact required for loadable kinds, UNLESS the descriptor carries + // the content inline. An mcp_server may ship its ``server_config`` directly + // in the descriptor (the common case โ€” the agent loader reads it from there), + // in which case a separate artifact is redundant. Only require an artifact + // when there is no inline content to load. ``cedar_policy_module`` / ``skill`` + // keep their bytes in the artifact, so they still require it. + if (typeof kind === 'string' && KINDS_REQUIRING_ARTIFACT.has(kind as RegistryAssetKind)) { + const hasArtifact = typeof input.artifact_b64 === 'string' && input.artifact_b64.length > 0; + const hasInlineContent = hasInlineDescriptorContent(kind as RegistryAssetKind, input.descriptor); + if (!hasArtifact && !hasInlineContent) { + v.push({ + field: 'artifact_b64', + message: `artifact_b64 is required for kind ${kind} (or provide inline content in the descriptor)`, + }); + } + } + + return v; +} + +/** + * True when the descriptor carries the loadable content inline, making a + * separate artifact unnecessary. Today only ``mcp_server`` supports this โ€” via + * a ``server_config`` object the agent loader writes straight into ``.mcp.json``. + */ +function hasInlineDescriptorContent(kind: RegistryAssetKind, descriptor: unknown): boolean { + if (typeof descriptor !== 'object' || descriptor === null) { + return false; + } + const d = descriptor as Record; + switch (kind) { + case 'mcp_server': + return typeof d.server_config === 'object' && d.server_config !== null; + case 'cedar_policy_module': + return typeof d.cedar_text === 'string' && d.cedar_text.length > 0; + case 'skill': + return typeof d.prompt_fragment === 'string' && d.prompt_fragment.length > 0; + default: + return false; + } +} + +/** Per-kind descriptor required-field checks (REGISTRY.md ยง3.3). */ +function validateKindDescriptor( + kind: RegistryAssetKind, + desc: Record, +): DescriptorViolation[] { + const v: DescriptorViolation[] = []; + switch (kind) { + case 'mcp_server': + if (desc.transport !== 'http' && desc.transport !== 'stdio') { + v.push({ field: 'descriptor.transport', message: "mcp_server transport must be 'http' or 'stdio'" }); + } + if (typeof desc.tool_prefix !== 'string' || desc.tool_prefix.length === 0) { + v.push({ field: 'descriptor.tool_prefix', message: 'mcp_server descriptor requires tool_prefix' }); + } + break; + case 'cedar_policy_module': + if (!Array.isArray(desc.cedar_actions)) { + v.push({ field: 'descriptor.cedar_actions', message: 'cedar_policy_module descriptor requires cedar_actions array' }); + } + v.push(...validateInlineContent('descriptor.cedar_text', desc.cedar_text)); + break; + case 'skill': + if (!Array.isArray(desc.tool_hints)) { + v.push({ field: 'descriptor.tool_hints', message: 'skill descriptor requires tool_hints array' }); + } + v.push(...validateInlineContent('descriptor.prompt_fragment', desc.prompt_fragment)); + break; + default: + break; + } + return v; +} + +/** + * Max size of inline text content (cedar_text / prompt_fragment) at publish. + * Bounds the DynamoDB item (400 KB hard limit) and keeps skill fragments from + * bloating every task's system prompt. If a future asset genuinely needs more, + * that's the trigger to move content to an S3/AgentCore-backed artifact โ€” the + * ``content`` seam on ResolvedAsset already makes that swap caller-invisible. + */ +export const MAX_INLINE_CONTENT_BYTES = 65_536; // 64 KiB + +/** Validate a required inline text field: present, a string, within the size cap. */ +function validateInlineContent(field: string, value: unknown): DescriptorViolation[] { + if (typeof value !== 'string' || value.length === 0) { + return [{ field, message: `${field} is required and must be a non-empty string` }]; + } + if (Buffer.byteLength(value, 'utf8') > MAX_INLINE_CONTENT_BYTES) { + return [{ + field, + message: `${field} exceeds the ${MAX_INLINE_CONTENT_BYTES}-byte inline limit`, + }]; + } + return []; +} + +/** Build the ``{kind}#{namespace}/{name}`` partition key. */ +export function publishPk(kind: string, namespace: string, name: string): string { + return `${kind}#${namespace}/${name}`; +} + +/** Build the S3 artifact key ``{kind}/{namespace}/{name}/{version}/artifact``. */ +export function artifactKey(kind: string, namespace: string, name: string, version: string): string { + return `${kind}/${namespace}/${name}/${version}/artifact`; +} + +/** Narrow a validated descriptor to the typed shape. */ +export function asDescriptor(d: unknown): RegistryDescriptor { + return d as RegistryDescriptor; +} diff --git a/cdk/src/handlers/shared/registry-ref.ts b/cdk/src/handlers/shared/registry-ref.ts new file mode 100644 index 000000000..99c31a26b --- /dev/null +++ b/cdk/src/handlers/shared/registry-ref.ts @@ -0,0 +1,111 @@ +/** + * 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. + */ + +/** + * Registry URI grammar (#246) โ€” the single, dependency-free source of truth on + * the TypeScript side. Kept free of any AWS SDK / runtime import so BOTH the + * Lambda resolver (``registry-resolver.ts``) AND the CDK construct layer + * (``constructs/blueprint.ts``, which validates refs at synth) can import it + * without dragging aws-sdk into the synth graph. + * + * The regex MUST stay byte-for-byte equivalent to the Python ``_REGISTRY_REF`` + * (agent/src/workflow/validator.py); the ``contracts/registry-resolution/`` + * corpus is the agreement both sides reproduce. See REGISTRY.md ยง6. + */ + +import type { RegistryAssetKind, RegistryRef } from './types'; + +/** + * registry:////@ + * kind snake_case: [a-z][a-z0-9_]* + * namespace [a-z][a-z0-9-]* + * name [a-z0-9][a-z0-9._-]* + * constraint MANDATORY: exact / caret / tilde semver only + */ +const REGISTRY_REF_RE = + /^registry:\/\/([a-z][a-z0-9_]*)\/([a-z][a-z0-9-]*)\/([a-z0-9][a-z0-9._-]*)@([\^~]?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/; + +/** Kinds that are valid targets of a registry ref. Mirrors REGISTRY.md ยง2. */ +export const KNOWN_KINDS: ReadonlySet = new Set([ + 'mcp_server', + 'cedar_policy_module', + 'skill', + 'plugin', + 'subagent', + 'prompt_fragment', + 'capability', +]); + +/** Specific, machine-readable reasons a resolution can fail (REGISTRY.md ยง5). */ +export type RegistryResolutionReason = + | 'INVALID_REGISTRY_REF' + | 'INVALID_CONSTRAINT' + | 'NO_MATCHING_VERSION' + | 'REMOVED'; + +/** + * Thrown when a ref cannot be resolved. Carries the specific {@link RegistryResolutionReason} + * and the offending ref so the create-task boundary can fail admission with + * ``REGISTRY_RESOLUTION_FAILED`` + reason (fail-closed; ADR-018 sub-decision 6). + */ +export class RegistryResolutionError extends Error { + readonly reason: RegistryResolutionReason; + readonly ref: string; + + constructor(reason: RegistryResolutionReason, ref: string, detail?: string) { + super(`registry resolution failed [${reason}] for ${ref}${detail ? `: ${detail}` : ''}`); + this.name = 'RegistryResolutionError'; + this.reason = reason; + this.ref = ref; + } +} + +/** + * Parse a ``registry://`` reference into its components. Grammar-only โ€” does not + * touch the catalog. Throws {@link RegistryResolutionError} with + * ``INVALID_REGISTRY_REF`` when the ref does not match the grammar (which + * includes a missing/floating constraint โ€” pins are mandatory). + */ +export function parseRef(ref: string): RegistryRef { + const m = REGISTRY_REF_RE.exec(ref); + if (!m) { + throw new RegistryResolutionError('INVALID_REGISTRY_REF', ref); + } + const [, kind, namespace, name, constraint] = m; + if (!KNOWN_KINDS.has(kind as RegistryAssetKind)) { + throw new RegistryResolutionError('INVALID_REGISTRY_REF', ref, `unknown kind ${kind}`); + } + return { kind: kind as RegistryAssetKind, namespace, name, constraint }; +} + +/** True iff ``ref`` matches the grammar. Never throws โ€” the non-throwing peer of + * {@link parseRef}, mirroring Python ``is_registry_ref``. */ +export function isRegistryRef(ref: string): boolean { + try { + parseRef(ref); + return true; + } catch { + return false; + } +} + +/** Partition key for a ref: ``{kind}#{namespace}/{name}`` (REGISTRY.md ยง3.1). */ +export function assetPk(kind: RegistryAssetKind, namespace: string, name: string): string { + return `${kind}#${namespace}/${name}`; +} diff --git a/cdk/src/handlers/shared/registry-resolver.ts b/cdk/src/handlers/shared/registry-resolver.ts new file mode 100644 index 000000000..8ee0ac904 --- /dev/null +++ b/cdk/src/handlers/shared/registry-resolver.ts @@ -0,0 +1,248 @@ +/** + * 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. + */ + +/** + * Registry asset resolver (#246). Turns a ``registry://kind/namespace/name@constraint`` + * reference into a concrete pinned {@link ResolvedAsset} by querying the + * ``RegistryAssetsTable`` and applying semver + status rules. + * + * This is the TypeScript half of the two-language ``RegistryClient`` seam + * (ADR-018 sub-decision 8): {@link parseRef}'s grammar MUST agree byte-for-byte + * with the Python ``_REGISTRY_REF`` (agent/src/workflow/validator.py), and the + * ``contracts/registry-resolution/`` corpus is the agreement both reproduce. + * + * See docs/design/REGISTRY.md ยง5 (resolution semantics) and ยง6 (grammar). + */ + +import { + DynamoDBClient, +} from '@aws-sdk/client-dynamodb'; +import { + DynamoDBDocumentClient, + QueryCommand, +} from '@aws-sdk/lib-dynamodb'; +import * as semver from 'semver'; +import { logger } from './logger'; +// Grammar (parseRef/isRegistryRef/assetPk/RegistryResolutionError) lives in the +// dependency-free registry-ref.ts so the CDK construct layer can validate refs +// at synth without pulling aws-sdk into the graph. Re-exported below so existing +// importers of this module keep working. +import { assetPk, parseRef, RegistryResolutionError } from './registry-ref'; +import type { + RegistryAssetRecord, + RegistryAssetStatus, + ResolvedAsset, + ResolvedAssetBundle, +} from './types'; + +export { assetPk, isRegistryRef, parseRef, RegistryResolutionError } from './registry-ref'; +export type { RegistryResolutionReason } from './registry-ref'; + +/** + * Translate an allowed constraint (exact / caret / tilde) into a + * ``semver``-compatible range string. Anything the grammar somehow let through + * that ``semver`` cannot parse is surfaced as ``INVALID_CONSTRAINT`` rather than + * silently matching nothing. + */ +function toSemverRange(constraint: string, ref: string): string { + // The grammar already restricts to exact / ^ / ~; semver accepts all three + // as-is. validRange guards against a future grammar loosening slipping a + // form through that would resolve to an unexpected range. + if (semver.validRange(constraint, { loose: false }) === null) { + throw new RegistryResolutionError('INVALID_CONSTRAINT', ref, constraint); + } + return constraint; +} + +/** Injectable DDB client so tests can supply a mock (default: real client). */ +export interface RegistryQueryClient { + send(command: QueryCommand): Promise<{ Items?: Record[] }>; +} + +let defaultClient: RegistryQueryClient | undefined; +function getDefaultClient(): RegistryQueryClient { + if (!defaultClient) { + defaultClient = DynamoDBDocumentClient.from(new DynamoDBClient({})) as unknown as RegistryQueryClient; + } + return defaultClient; +} + +/** + * Resolve a single ``registry://`` ref against the catalog. + * + * 1. Parse the ref (grammar; may throw ``INVALID_REGISTRY_REF``). + * 2. Query every version under the asset's partition. + * 3. Rank candidates by parsed semver (descending); pick the highest whose + * version satisfies the constraint AND whose status is resolvable. + * 4. Apply status rules (REGISTRY.md ยง5): ``approved`` resolves silently, + * ``deprecated`` resolves with a ``DEPRECATED`` warning, others are not + * candidates; if the single highest match is ``removed`` โ†’ ``REMOVED``. + * + * @throws {@link RegistryResolutionError} on any unresolved ref (fail-closed). + */ +export async function resolveRef( + ref: string, + opts?: { client?: RegistryQueryClient; tableName?: string }, +): Promise { + const parsed = parseRef(ref); + const range = toSemverRange(parsed.constraint, ref); + const client = opts?.client ?? getDefaultClient(); + const tableName = opts?.tableName ?? process.env.REGISTRY_ASSETS_TABLE_NAME; + if (!tableName) { + throw new RegistryResolutionError( + 'NO_MATCHING_VERSION', + ref, + 'REGISTRY_ASSETS_TABLE_NAME not configured', + ); + } + + const pk = assetPk(parsed.kind, parsed.namespace, parsed.name); + const result = await client.send( + new QueryCommand({ + TableName: tableName, + KeyConditionExpression: 'pk = :pk', + ExpressionAttributeValues: { ':pk': pk }, + }), + ); + const rows = (result.Items ?? []) as unknown as RegistryAssetRecord[]; + + // Candidates whose version satisfies the constraint AND is a valid semver. + // DynamoDB returns rows sorted lexicographically by the version sort key, + // which is WRONG for semver (1.10.0 < 1.9.0 as strings) โ€” so we always rank + // in code (REGISTRY.md ยง3.2). + const matching = rows + .filter((r) => semver.valid(r.version) !== null && semver.satisfies(r.version, range, { includePrerelease: false })) + .sort((a, b) => semver.rcompare(a.version, b.version)); + + if (matching.length === 0) { + throw new RegistryResolutionError('NO_MATCHING_VERSION', ref); + } + + // Highest matching version โ€” but its status decides whether it resolves. + const top = matching[0]; + const warnings = statusWarnings(top.status, ref, top.version); + + logger.info('registry ref resolved', { + ref, + resolved_version: top.version, + status: top.status, + warnings, + }); + + return { + kind: top.kind, + namespace: top.namespace, + name: top.name, + version: top.version, + descriptor: top.descriptor, + // Substrate-agnostic loadable text. MVP reads it inline from the descriptor + // (cedar_text / prompt_fragment); a future S3/AgentCore substrate would + // fetch it here instead โ€” callers only ever see ``content``, never the + // storage location. Undefined for kinds with no text body (mcp_server). + content: extractContent(top), + // artifact_url is attached by the resolve handler (presign); the library + // returns descriptor-level data only. + warnings, + }; +} + +/** + * Extract the loadable text body from a record's descriptor (REGISTRY.md ยง3.3). + * This is the single place that knows WHERE inline content lives, so swapping to + * an S3/AgentCore-backed fetch later is confined here. + */ +function extractContent(record: RegistryAssetRecord): string | undefined { + const d = record.descriptor as Record; + const raw = record.kind === 'cedar_policy_module' + ? d.cedar_text + : record.kind === 'skill' + ? d.prompt_fragment + : undefined; + return typeof raw === 'string' ? raw : undefined; +} + +/** + * Apply the status rules to the winning candidate. Returns the warnings list + * for resolvable statuses; throws for non-resolvable ones. ``removed`` is a + * distinct reason so operators can tell a tombstoned pin from one that never + * existed (REGISTRY.md ยง5). + */ +function statusWarnings(status: RegistryAssetStatus, ref: string, version: string): string[] { + switch (status) { + case 'approved': + return []; + case 'deprecated': + return ['DEPRECATED']; + case 'removed': + throw new RegistryResolutionError('REMOVED', ref, `version ${version} is removed`); + case 'submitted': + case 'draft': + case 'rejected': + // The highest semver match is not in a resolvable state and no lower + // approved version was selected โ€” treat as no usable match. + throw new RegistryResolutionError('NO_MATCHING_VERSION', ref, `highest match ${version} is ${status}`); + } +} + +/** + * Resolve many refs in parallel into a {@link ResolvedAssetBundle} grouped by + * kind. Any single unresolved ref rejects the whole call (fail-closed) โ€” a task + * with a bad pin must not partially resolve. + */ +export async function resolveAll( + refs: readonly string[], + opts?: { client?: RegistryQueryClient; tableName?: string }, +): Promise { + // A task declares a handful of asset refs (bounded by blueprint authoring), + // so unbounded parallelism here is acceptable โ€” this is not driven by + // unbounded external input. + // eslint-disable-next-line @cdklabs/promiseall-no-unbounded-parallelism + const resolved = await Promise.all(refs.map((ref) => resolveRef(ref, opts))); + + const bundle: { + mcp_servers: ResolvedAsset[]; + cedar_policy_modules: ResolvedAsset[]; + skills: ResolvedAsset[]; + } = { mcp_servers: [], cedar_policy_modules: [], skills: [] }; + + for (const asset of resolved) { + switch (asset.kind) { + case 'mcp_server': + bundle.mcp_servers.push(asset); + break; + case 'cedar_policy_module': + bundle.cedar_policy_modules.push(asset); + break; + case 'skill': + bundle.skills.push(asset); + break; + default: + // Reserved kinds are declared in the grammar but have no loader yet + // (REGISTRY.md ยง2); refusing here keeps the fail-closed guarantee + // rather than silently dropping a resolved-but-unloadable asset. + throw new RegistryResolutionError( + 'INVALID_REGISTRY_REF', + `registry://${asset.kind}/${asset.namespace}/${asset.name}`, + `kind ${asset.kind} has no loader in MVP`, + ); + } + } + + return bundle; +} diff --git a/cdk/src/handlers/shared/repo-config.ts b/cdk/src/handlers/shared/repo-config.ts index 1753d5685..2e066fed1 100644 --- a/cdk/src/handlers/shared/repo-config.ts +++ b/cdk/src/handlers/shared/repo-config.ts @@ -42,6 +42,22 @@ export interface RepoConfig { readonly poll_interval_ms?: number; readonly egress_allowlist?: string[]; readonly cedar_policies?: string[]; + /** + * Registry (#246): MCP server refs (``registry://mcp_server/...@constraint``) + * pinned by this repo's Blueprint. Resolved at the create-task boundary and + * merged into the agent's ``.mcp.json``. Written by the Blueprint construct. + */ + readonly mcp_servers?: string[]; + /** + * Registry (#246): Cedar policy module refs. Their resolved text is merged + * into the ``cedar_policies`` payload (byte-identical to inline policies). + */ + readonly cedar_policy_modules?: string[]; + /** + * Registry (#246): skill refs. Their resolved prompt fragments are appended + * to the agent's system prompt. + */ + readonly skills?: string[]; /** * Cedar HITL: per-blueprint override for the per-task approval-gate cap * (design decision #13, ยง4 step 5). Written by the Blueprint construct @@ -69,6 +85,15 @@ export interface BlueprintConfig { readonly poll_interval_ms?: number; readonly egress_allowlist?: string[]; readonly cedar_policies?: string[]; + /** + * Registry (#246): MCP server refs surfaced from RepoConfig so the + * orchestrator's resolve-registry step can resolve them at task start. + */ + readonly mcp_servers?: string[]; + /** Registry (#246): Cedar policy module refs, resolved at task start. */ + readonly cedar_policy_modules?: string[]; + /** Registry (#246): skill refs, resolved at task start. */ + readonly skills?: string[]; /** * Cedar HITL: per-blueprint approval-gate cap override. Surfaced from * RepoConfig so downstream consumers (admission, orchestrator payload) diff --git a/cdk/src/handlers/shared/response.ts b/cdk/src/handlers/shared/response.ts index f4d370877..c1c045382 100644 --- a/cdk/src/handlers/shared/response.ts +++ b/cdk/src/handlers/shared/response.ts @@ -54,6 +54,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.md ยง4/ยง5). + REGISTRY_VERSION_EXISTS: 'REGISTRY_VERSION_EXISTS', + REGISTRY_ASSET_NOT_FOUND: 'REGISTRY_ASSET_NOT_FOUND', + REGISTRY_RESOLUTION_FAILED: 'REGISTRY_RESOLUTION_FAILED', } as const; const COMMON_HEADERS = { diff --git a/cdk/src/handlers/shared/types.ts b/cdk/src/handlers/shared/types.ts index dc4d9c843..b624119e6 100644 --- a/cdk/src/handlers/shared/types.ts +++ b/cdk/src/handlers/shared/types.ts @@ -47,6 +47,167 @@ export type ResolvedWorkflow = { readonly version: string; }; +// --- Agent asset registry (#246) -------------------------------------------- +// See docs/design/REGISTRY.md and ADR-018. The registry is a versioned, +// immutable-per-version catalog of runtime artifacts referenced from Blueprints +// via ``registry://kind/namespace/name@constraint`` and resolved at the +// create-task boundary. Types below are the shared contract between the CDK +// handlers/resolver and (for the wire-facing ones) the CLI. + +/** + * Registry asset kinds. MVP loads ``mcp_server`` end-to-end; ``cedar_policy_module`` + * and ``skill`` are resolved and staged. The remaining kinds are declared so the + * grammar is stable but are rejected at publish until a loader ships + * (ADR-018 sub-decision 1; REGISTRY.md ยง2). + * + * Keep in sync with ``cli/src/types.ts::RegistryAssetKind``. + */ +export type RegistryAssetKind = + | 'mcp_server' + | 'cedar_policy_module' + | 'skill' + | 'plugin' + | 'subagent' + | 'prompt_fragment' + | 'capability'; + +/** + * Lifecycle status of a registry asset version (ADR-018 sub-decision 4/10; + * REGISTRY.md ยง5). ``approved`` is the single canonical resolvable state โ€” the + * ADR prose word "active" is NOT a value in code (one token, byte-for-byte, + * across the TS resolver and the Python loader). + * + * Resolution: ``approved`` resolves silently; ``deprecated`` resolves with a + * warning; ``submitted``/``draft``/``rejected`` are not candidates; ``removed`` + * fails with a distinct ``REMOVED`` reason. + * + * Keep in sync with ``cli/src/types.ts::RegistryAssetStatus``. + */ +export type RegistryAssetStatus = + | 'draft' + | 'submitted' + | 'approved' + | 'rejected' + | 'deprecated' + | 'removed'; + +/** + * A parsed ``registry://kind/namespace/name@constraint`` reference. The + * ``constraint`` is mandatory (fail-closed; ADR-018 sub-decision 6) and is one + * of exact (``1.4.1``), caret (``^1.4.1``), or tilde (``~1.4.1``). Produced by + * ``registry-resolver.ts::parseRef``; its grammar must agree byte-for-byte with + * the Python ``_REGISTRY_REF`` (contracts/registry-resolution/). + * + * Keep in sync with ``cli/src/types.ts::RegistryRef``. + */ +export type RegistryRef = { + readonly kind: RegistryAssetKind; + readonly namespace: string; + readonly name: string; + readonly constraint: string; +}; + +/** + * One audit entry appended to a record on every lifecycle transition + * (REGISTRY.md ยง3.1/ยง10). Append-only; rich actor+timestamp+rationale is a MUST. + */ +export interface RegistryStatusEvent { + readonly status: RegistryAssetStatus; + /** Cognito ``sub`` of the actor who caused the transition. */ + readonly actor: string; + readonly at: string; + readonly rationale?: string; +} + +/** + * Typed per-kind descriptor validated at publish (REGISTRY.md ยง3.3). Shared + * required fields plus kind-specific keys carried as an open map (each handler + * validates its kind's required keys). ``artifact`` marks fields whose bytes + * live in S3 rather than inline. + */ +export interface RegistryDescriptor { + readonly summary: string; + readonly permissions: readonly string[]; + readonly [key: string]: unknown; +} + +/** + * Full registry asset record as stored in ``RegistryAssetsTable`` (REGISTRY.md + * ยง3.1). One row per published ``(kind, namespace, name, version)``. Immutable + * once written except for ``status`` and ``status_history``. + */ +export interface RegistryAssetRecord { + /** Partition key: ``{kind}#{namespace}/{name}``. */ + readonly pk: string; + /** Sort key: the semver ``version`` string. */ + readonly sk: string; + readonly kind: RegistryAssetKind; + readonly namespace: string; + readonly name: string; + readonly version: string; + readonly descriptor: RegistryDescriptor; + /** S3 key of the artifact bytes; empty for descriptor-only kinds. */ + readonly artifact_ref?: string; + readonly status: RegistryAssetStatus; + /** Cognito ``sub`` of the publishing principal. */ + readonly publisher: string; + readonly created_at: string; + readonly status_history: readonly RegistryStatusEvent[]; +} + +/** + * The result of resolving a single ``registry://`` ref against the catalog + * (REGISTRY.md ยง4.2). Carries the descriptor, the pinned version chosen, and + * any resolution warnings (e.g. ``DEPRECATED``). The artifact bytes are fetched + * separately (presigned URL) so this stays small on the task payload. + * + * Keep in sync with ``cli/src/types.ts::ResolvedAsset``. + */ +export interface ResolvedAsset { + readonly kind: RegistryAssetKind; + readonly namespace: string; + readonly name: string; + readonly version: string; + readonly descriptor: RegistryDescriptor; + /** + * The loadable text content of the asset โ€” Cedar policy text for a + * ``cedar_policy_module``, the prompt fragment for a ``skill``. Substrate- + * agnostic on purpose: the resolver populates it (MVP reads it inline from + * the descriptor; a future AgentCore/S3 substrate would fetch it) so callers + * never depend on WHERE the bytes live. Absent for kinds with no text body + * (e.g. ``mcp_server``, whose config rides in the descriptor's server_config). + */ + readonly content?: string; + readonly artifact_url?: string; + readonly warnings: readonly string[]; +} + +/** + * The bundle attached to the agent payload after resolving all of a task's + * registry refs (REGISTRY.md ยง7/ยง8). Grouped by kind so the agent-side loader + * applies each with the right mechanism. + * + * Keep in sync with ``cli/src/types.ts::ResolvedAssetBundle``. + */ +export interface ResolvedAssetBundle { + readonly mcp_servers: readonly ResolvedAsset[]; + readonly cedar_policy_modules: readonly ResolvedAsset[]; + readonly skills: readonly ResolvedAsset[]; +} + +/** + * Compact audit triple stamped on the TaskRecord โ€” ``{kind, id, version}`` โ€” + * answering "what asset versions did this task run" from a single field + * (ADR-018 sub-decision 5; REGISTRY.md ยง7). ``id`` is ``namespace/name``. + * + * Keep in sync with ``cli/src/types.ts::ResolvedAssetSummary``. + */ +export interface ResolvedAssetSummary { + readonly kind: RegistryAssetKind; + readonly id: string; + readonly version: string; +} + /** Shared across all attachment interfaces. Add new types here (e.g., 'audio'). */ export type AttachmentType = 'image' | 'file' | 'url'; @@ -85,6 +246,11 @@ 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 at the create-task boundary from the + * blueprint's pins โ€” ``{kind, id, version}`` triples stamped for audit + * ("what asset versions did this task run"). Absent when the blueprint + * pinned no assets. See docs/design/REGISTRY.md ยง7. */ + readonly resolved_assets?: ResolvedAssetSummary[]; readonly pr_number?: number; readonly task_description?: string; readonly branch_name: string; @@ -263,6 +429,9 @@ export interface TaskDetail { readonly repo: string | null; readonly issue_number: number | null; readonly resolved_workflow: ResolvedWorkflow | null; + /** Registry assets (#246) resolved for this task, or ``null`` when none + * were pinned. See docs/design/REGISTRY.md ยง7. */ + readonly resolved_assets: ResolvedAssetSummary[] | null; readonly pr_number: number | null; readonly task_description: string | null; readonly branch_name: string; @@ -337,6 +506,9 @@ export interface TaskSummary { readonly repo: string | null; readonly issue_number: number | null; readonly resolved_workflow: ResolvedWorkflow | null; + /** Registry assets (#246) resolved for this task, or ``null`` when none + * were pinned. See docs/design/REGISTRY.md ยง7. */ + readonly resolved_assets: ResolvedAssetSummary[] | null; readonly pr_number: number | null; readonly task_description: string | null; readonly branch_name: string; @@ -711,6 +883,7 @@ export function toTaskDetail(record: TaskRecord): TaskDetail { repo: record.repo ?? null, issue_number: record.issue_number ?? null, resolved_workflow: record.resolved_workflow ?? null, + resolved_assets: record.resolved_assets ?? null, pr_number: record.pr_number ?? null, task_description: record.task_description ?? null, branch_name: record.branch_name, @@ -891,6 +1064,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 337360afc..d3b9092eb 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -46,6 +46,8 @@ import { GitHubScreenshotIntegration } from '../constructs/github-screenshot-int import { JiraIntegration } from '../constructs/jira-integration'; import { LinearIntegration } from '../constructs/linear-integration'; import { PendingUploadCleanup } from '../constructs/pending-upload-cleanup'; +import { RegistryArtifactsBucket } from '../constructs/registry-artifacts-bucket'; +import { RegistryAssetsTable } from '../constructs/registry-assets-table'; import { RepoTable } from '../constructs/repo-table'; import { SlackIntegration } from '../constructs/slack-integration'; import { StrandedTaskReconciler } from '../constructs/stranded-task-reconciler'; @@ -107,6 +109,20 @@ export class AgentStack extends Stack { const webhookTable = new WebhookTable(this, 'WebhookTable'); const repoTable = new RepoTable(this, 'RepoTable'); + // Agent asset registry (#246). Storage + API only in PR 1 โ€” nothing in the + // orchestrator/agent reads from these yet (purely additive). The + // orchestrator resolve step and agent-side loaders follow in PR 2. The + // TaskApi wires the /registry routes when both handles are supplied. + const registryAssetsTable = new RegistryAssetsTable(this, 'RegistryAssetsTable'); + const registryArtifactsBucket = new RegistryArtifactsBucket(this, 'RegistryArtifactsBucket'); + + NagSuppressions.addResourceSuppressions(registryArtifactsBucket.bucket, [ + { + id: 'AwsSolutions-S1', + reason: 'Registry artifact bytes (REGISTRY.md ยง3.4): writes confined to the publish Lambda (RegistryPublisher/Approver-gated) via grantPut; reads only via short-lived presigned URLs from the authn\'d resolve handler. Versioned for audit; immutability enforced at the DynamoDB write. A separate access-log bucket is not justified for this admin-published catalog.', + }, + ]); + // 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 @@ -298,6 +314,8 @@ export class AgentStack extends Stack { traceArtifactsBucket: traceArtifactsBucket.bucket, attachmentsBucket: attachmentsBucket.bucket, userConcurrencyTable: userConcurrencyTable.table, + registryAssetsTable: registryAssetsTable.table, + registryArtifactsBucket: registryArtifactsBucket.bucket, }); // --- AgentCore Runtime (IAM-authed orchestrator path) --- @@ -653,6 +671,7 @@ export class AgentStack extends Stack { taskEventsTable: taskEventsTable.table, userConcurrencyTable: userConcurrencyTable.table, repoTable: repoTable.table, + registryAssetsTable: registryAssetsTable.table, runtimeArn: runtime.agentRuntimeArn, githubTokenSecretArn: githubTokenSecret.secretArn, memoryId: agentMemory.memory.memoryId, diff --git a/cdk/test/constructs/blueprint.test.ts b/cdk/test/constructs/blueprint.test.ts index 57eca5096..7de5f7abf 100644 --- a/cdk/test/constructs/blueprint.test.ts +++ b/cdk/test/constructs/blueprint.test.ts @@ -592,3 +592,159 @@ describe('Blueprint validation', () => { expect(() => app.synth()).not.toThrow(); }); }); + +// --- #246: assets.mcpServers ------------------------------------------------- + +describe('Blueprint assets.mcpServers (#246)', () => { + const validRef = 'registry://mcp_server/acme/pdf-tools@^1.4.1'; + + test('exposes mcpServers as a public property', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + const repoTable = new dynamodb.Table(stack, 'RepoTable', { + partitionKey: { name: 'repo', type: dynamodb.AttributeType.STRING }, + }); + const blueprint = new Blueprint(stack, 'Blueprint', { + repo: 'org/my-repo', + repoTable, + assets: { mcpServers: [validRef] }, + }); + expect(blueprint.mcpServers).toEqual([validRef]); + }); + + test('defaults to an empty list when absent', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + const repoTable = new dynamodb.Table(stack, 'RepoTable', { + partitionKey: { name: 'repo', type: dynamodb.AttributeType.STRING }, + }); + const blueprint = new Blueprint(stack, 'Blueprint', { repo: 'org/my-repo', repoTable }); + expect(blueprint.mcpServers).toEqual([]); + }); + + test('persists mcp_servers to the DynamoDB item on create', () => { + const { template } = createStack({ assets: { mcpServers: [validRef] } }); + const serialized = getCreateJoinParts(template).join(''); + expect(serialized).toContain('mcp_servers'); + expect(serialized).toContain(validRef); + }); + + test('onUpdate includes mcp_servers in the UpdateExpression', () => { + const { template } = createStack({ assets: { mcpServers: [validRef] } }); + const serialized = getUpdateJoinParts(template).join(''); + expect(serialized).toContain('#mcp_servers'); + }); + + test('omits mcp_servers when none configured', () => { + const { template } = createStack(); + const serialized = getCreateJoinParts(template).join(''); + expect(serialized).not.toContain('mcp_servers'); + }); + + test('accepts exact, caret, and tilde pins', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + const repoTable = new dynamodb.Table(stack, 'RepoTable', { + partitionKey: { name: 'repo', type: dynamodb.AttributeType.STRING }, + }); + new Blueprint(stack, 'Blueprint', { + repo: 'org/my-repo', + repoTable, + assets: { + mcpServers: [ + 'registry://mcp_server/acme/a@1.0.0', + 'registry://mcp_server/acme/b@^2.1.0', + 'registry://mcp_server/acme/c@~3.0.1', + ], + }, + }); + expect(() => app.synth()).not.toThrow(); + }); + + test.each([ + ['unpinned', 'registry://mcp_server/acme/pdf-tools'], + ['floating latest', 'registry://mcp_server/acme/pdf-tools@latest'], + ['range operator', 'registry://mcp_server/acme/pdf-tools@>=1.0.0'], + ['legacy 2-segment', 'registry://mcp/pdf-tools'], + ['hyphen kind', 'registry://mcp-server/acme/pdf-tools@1.0.0'], + ])('rejects an invalid ref at synth: %s', (_label, badRef) => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + const repoTable = new dynamodb.Table(stack, 'RepoTable', { + partitionKey: { name: 'repo', type: dynamodb.AttributeType.STRING }, + }); + new Blueprint(stack, 'Blueprint', { + repo: 'org/my-repo', + repoTable, + assets: { mcpServers: [badRef] }, + }); + expect(() => app.synth()).toThrow(/Invalid registry ref/); + }); +}); + +// --- #246 PR 3: cedarPolicyModules + skills ----------------------------------- + +describe('Blueprint assets.cedarPolicyModules + skills (#246 PR 3)', () => { + const cedarRef = 'registry://cedar_policy_module/acme/guard@^1.0.0'; + const skillRef = 'registry://skill/acme/refactor@^1.0.0'; + + test('exposes cedarPolicyModules and skills as public properties', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + const repoTable = new dynamodb.Table(stack, 'RepoTable', { + partitionKey: { name: 'repo', type: dynamodb.AttributeType.STRING }, + }); + const blueprint = new Blueprint(stack, 'Blueprint', { + repo: 'org/my-repo', + repoTable, + assets: { cedarPolicyModules: [cedarRef], skills: [skillRef] }, + }); + expect(blueprint.cedarPolicyModules).toEqual([cedarRef]); + expect(blueprint.skills).toEqual([skillRef]); + }); + + test('persists cedar_policy_modules and skills columns on create', () => { + const { template } = createStack({ + assets: { cedarPolicyModules: [cedarRef], skills: [skillRef] }, + }); + const serialized = getCreateJoinParts(template).join(''); + expect(serialized).toContain('cedar_policy_modules'); + expect(serialized).toContain(cedarRef); + expect(serialized).toContain('skills'); + expect(serialized).toContain(skillRef); + }); + + test('inline security.cedarPolicies and registry cedarPolicyModules coexist', () => { + // The mixed case the plan calls out: one inline Cedar policy + one registry + // module pin on the same blueprint. Inline lives in cedar_policies; the + // registry ref lives in cedar_policy_modules โ€” distinct columns, no clash. + const app = new App(); + const stack = new Stack(app, 'TestStack'); + const repoTable = new dynamodb.Table(stack, 'RepoTable', { + partitionKey: { name: 'repo', type: dynamodb.AttributeType.STRING }, + }); + const blueprint = new Blueprint(stack, 'Blueprint', { + repo: 'org/my-repo', + repoTable, + security: { cedarPolicies: ['permit (principal, action, resource);'] }, + assets: { cedarPolicyModules: [cedarRef] }, + }); + expect(blueprint.cedarPolicies).toHaveLength(1); + expect(blueprint.cedarPolicyModules).toEqual([cedarRef]); + expect(() => app.synth()).not.toThrow(); + }); + + test('rejects invalid cedarPolicyModules / skills refs at synth', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + const repoTable = new dynamodb.Table(stack, 'RepoTable', { + partitionKey: { name: 'repo', type: dynamodb.AttributeType.STRING }, + }); + new Blueprint(stack, 'Blueprint', { + repo: 'org/my-repo', + repoTable, + assets: { skills: ['registry://skill/acme/refactor'] }, // unpinned โ†’ invalid + }); + expect(() => app.synth()).toThrow(/Invalid registry ref/); + }); +}); diff --git a/cdk/test/constructs/registry-artifacts-bucket.test.ts b/cdk/test/constructs/registry-artifacts-bucket.test.ts new file mode 100644 index 000000000..8a6fce31b --- /dev/null +++ b/cdk/test/constructs/registry-artifacts-bucket.test.ts @@ -0,0 +1,150 @@ +/** + * 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, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { Match, Template } from 'aws-cdk-lib/assertions'; +import { + REGISTRY_ARTIFACT_NONCURRENT_TTL_DAYS, + RegistryArtifactsBucket, +} from '../../src/constructs/registry-artifacts-bucket'; + +describe('RegistryArtifactsBucket', () => { + let template: Template; + + beforeEach(() => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new RegistryArtifactsBucket(stack, 'RegistryArtifactsBucket'); + template = Template.fromStack(stack); + }); + + test('blocks all public access', () => { + template.hasResourceProperties('AWS::S3::Bucket', { + PublicAccessBlockConfiguration: { + BlockPublicAcls: true, + BlockPublicPolicy: true, + IgnorePublicAcls: true, + RestrictPublicBuckets: true, + }, + }); + }); + + test('enables S3-managed server-side encryption', () => { + template.hasResourceProperties('AWS::S3::Bucket', { + BucketEncryption: { + ServerSideEncryptionConfiguration: [ + { ServerSideEncryptionByDefault: { SSEAlgorithm: 'AES256' } }, + ], + }, + }); + }); + + test('enables versioning (artifacts are durable, referenced by pinned tasks)', () => { + template.hasResourceProperties('AWS::S3::Bucket', { + VersioningConfiguration: { Status: 'Enabled' }, + }); + }); + + test('enforces TLS-only access via bucket policy', () => { + template.hasResourceProperties('AWS::S3::BucketPolicy', { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Effect: 'Deny', + Action: 's3:*', + Condition: { Bool: { 'aws:SecureTransport': 'false' } }, + }), + ]), + }, + }); + }); + + test('expires noncurrent versions rather than current objects', () => { + template.hasResourceProperties('AWS::S3::Bucket', { + LifecycleConfiguration: { + Rules: Match.arrayWith([ + Match.objectLike({ + Id: 'registry-artifact-noncurrent-expiry', + Status: 'Enabled', + NoncurrentVersionExpiration: { + NoncurrentDays: REGISTRY_ARTIFACT_NONCURRENT_TTL_DAYS, + }, + }), + ]), + }, + }); + }); + + test('does NOT set a whole-object expiration (artifacts are durable)', () => { + const buckets = template.findResources('AWS::S3::Bucket'); + const [bucket] = Object.values(buckets); + const rules = bucket.Properties.LifecycleConfiguration.Rules as Array>; + for (const rule of rules) { + expect(rule.ExpirationInDays).toBeUndefined(); + } + }); + + test('aborts incomplete multipart uploads within a day', () => { + template.hasResourceProperties('AWS::S3::Bucket', { + LifecycleConfiguration: { + Rules: Match.arrayWith([ + Match.objectLike({ + AbortIncompleteMultipartUpload: { DaysAfterInitiation: 1 }, + }), + ]), + }, + }); + }); + + test('sets DESTROY removal policy + autoDelete by default', () => { + template.hasResource('AWS::S3::Bucket', { + DeletionPolicy: 'Delete', + UpdateReplacePolicy: 'Delete', + }); + template.hasResourceProperties('Custom::S3AutoDeleteObjects', { + BucketName: Match.anyValue(), + }); + }); + + test('exposes a bucket handle via the `bucket` property', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + const b = new RegistryArtifactsBucket(stack, 'RegistryArtifactsBucket'); + expect(b.bucket).toBeDefined(); + expect(b.bucket.bucketName).toBeDefined(); + }); +}); + +describe('RegistryArtifactsBucket with custom props', () => { + test('accepts a RETAIN removal policy and disables autoDelete', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new RegistryArtifactsBucket(stack, 'RegistryArtifactsBucket', { + removalPolicy: RemovalPolicy.RETAIN, + autoDeleteObjects: false, + }); + const template = Template.fromStack(stack); + + template.hasResource('AWS::S3::Bucket', { + DeletionPolicy: 'Retain', + UpdateReplacePolicy: 'Retain', + }); + expect(Object.keys(template.findResources('Custom::S3AutoDeleteObjects'))).toHaveLength(0); + }); +}); diff --git a/cdk/test/constructs/registry-assets-table.test.ts b/cdk/test/constructs/registry-assets-table.test.ts new file mode 100644 index 000000000..46df56e30 --- /dev/null +++ b/cdk/test/constructs/registry-assets-table.test.ts @@ -0,0 +1,123 @@ +/** + * 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, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import { Match, Template } from 'aws-cdk-lib/assertions'; +import { + REGISTRY_KIND_INDEX, + RegistryAssetsTable, +} from '../../src/constructs/registry-assets-table'; + +describe('RegistryAssetsTable', () => { + let template: Template; + + beforeEach(() => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new RegistryAssetsTable(stack, 'RegistryAssetsTable'); + template = Template.fromStack(stack); + }); + + test('creates a table with a composite pk/sk key schema', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + KeySchema: [ + { AttributeName: 'pk', KeyType: 'HASH' }, + { AttributeName: 'sk', KeyType: 'RANGE' }, + ], + }); + }); + + test('uses on-demand billing', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + BillingMode: 'PAY_PER_REQUEST', + }); + }); + + test('defines the kind-index GSI (kind HASH, pk RANGE) with ALL projection', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + GlobalSecondaryIndexes: Match.arrayWith([ + Match.objectLike({ + IndexName: REGISTRY_KIND_INDEX, + KeySchema: [ + { AttributeName: 'kind', KeyType: 'HASH' }, + { AttributeName: 'pk', KeyType: 'RANGE' }, + ], + Projection: { ProjectionType: 'ALL' }, + }), + ]), + }); + }); + + test('declares the attribute definitions the base key and GSI need', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + AttributeDefinitions: Match.arrayWith([ + { AttributeName: 'pk', AttributeType: 'S' }, + { AttributeName: 'sk', AttributeType: 'S' }, + { AttributeName: 'kind', AttributeType: 'S' }, + ]), + }); + }); + + test('enables point-in-time recovery by default', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + PointInTimeRecoverySpecification: { PointInTimeRecoveryEnabled: true }, + }); + }); + + test('does NOT configure a TTL (records are immutable + audited, not expired)', () => { + const tables = template.findResources('AWS::DynamoDB::Table'); + const [table] = Object.values(tables); + expect(table.Properties.TimeToLiveSpecification).toBeUndefined(); + }); + + test('sets DESTROY removal policy by default', () => { + template.hasResource('AWS::DynamoDB::Table', { + DeletionPolicy: 'Delete', + UpdateReplacePolicy: 'Delete', + }); + }); + + test('exposes a table handle via the `table` property', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + const t = new RegistryAssetsTable(stack, 'RegistryAssetsTable'); + expect(t.table).toBeDefined(); + expect(t.table.tableName).toBeDefined(); + }); +}); + +describe('RegistryAssetsTable with custom props', () => { + test('accepts a RETAIN removal policy and disables PITR', () => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new RegistryAssetsTable(stack, 'RegistryAssetsTable', { + removalPolicy: RemovalPolicy.RETAIN, + pointInTimeRecovery: false, + }); + const template = Template.fromStack(stack); + + template.hasResource('AWS::DynamoDB::Table', { + DeletionPolicy: 'Retain', + UpdateReplacePolicy: 'Retain', + }); + template.hasResourceProperties('AWS::DynamoDB::Table', { + PointInTimeRecoverySpecification: { PointInTimeRecoveryEnabled: false }, + }); + }); +}); diff --git a/cdk/test/constructs/task-api.test.ts b/cdk/test/constructs/task-api.test.ts index 79fd7fa9a..8bd3a297b 100644 --- a/cdk/test/constructs/task-api.test.ts +++ b/cdk/test/constructs/task-api.test.ts @@ -461,6 +461,78 @@ describe('TaskApi construct with webhooks', () => { }); }); +describe('TaskApi construct with registry (#246)', () => { + function createStackWithRegistry(): Template { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + const taskTable = new dynamodb.Table(stack, 'TaskTable', { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + }); + const taskEventsTable = new dynamodb.Table(stack, 'TaskEventsTable', { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'event_id', type: dynamodb.AttributeType.STRING }, + }); + const registryAssetsTable = new dynamodb.Table(stack, 'RegistryAssetsTable', { + partitionKey: { name: 'pk', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'sk', type: dynamodb.AttributeType.STRING }, + }); + const registryArtifactsBucket = new s3.Bucket(stack, 'RegistryArtifactsBucket'); + + new TaskApi(stack, 'TaskApi', { + taskTable, + taskEventsTable, + registryAssetsTable, + registryArtifactsBucket, + }); + return Template.fromStack(stack); + } + + let template: Template; + beforeAll(() => { + template = createStackWithRegistry(); + }); + + test('creates the /registry resource tree', () => { + for (const pathPart of ['registry', 'assets', 'resolve', '{kind}', '{namespace}', '{name}']) { + template.hasResourceProperties('AWS::ApiGateway::Resource', { PathPart: pathPart }); + } + }); + + test('adds 4 registry methods (POST+GET /assets, GET show, GET resolve)', () => { + const methods = template.findResources('AWS::ApiGateway::Method'); + const nonOptions = Object.values(methods).filter( + (r) => (r as { Properties: { HttpMethod: string } }).Properties.HttpMethod !== 'OPTIONS', + ); + // 6 base (incl. GET replay) + 4 registry + expect(nonOptions.length).toBe(10); + }); + + test('registry Lambdas receive the table + bucket env vars', () => { + template.hasResourceProperties('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + REGISTRY_ASSETS_TABLE_NAME: Match.anyValue(), + REGISTRY_ARTIFACTS_BUCKET_NAME: Match.anyValue(), + }), + }, + }); + }); + + test('creates the RegistryPublisher and RegistryApprover Cognito groups', () => { + template.hasResourceProperties('AWS::Cognito::UserPoolGroup', { GroupName: 'RegistryPublisher' }); + template.hasResourceProperties('AWS::Cognito::UserPoolGroup', { GroupName: 'RegistryApprover' }); + }); + + test('is not created when the registry props are absent', () => { + const bare = createStack().template; + const resources = bare.findResources('AWS::ApiGateway::Resource'); + const hasRegistry = Object.values(resources).some( + (r) => (r as { Properties?: { PathPart?: string } }).Properties?.PathPart === 'registry', + ); + expect(hasRegistry).toBe(false); + }); +}); + describe('TaskApi construct โ€” nudge endpoint (Phase 2)', () => { let nudgeTemplate: Template; diff --git a/cdk/test/handlers/registry-list-show.test.ts b/cdk/test/handlers/registry-list-show.test.ts new file mode 100644 index 000000000..9159b5827 --- /dev/null +++ b/cdk/test/handlers/registry-list-show.test.ts @@ -0,0 +1,132 @@ +/** + * 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. + */ + +const mockDdbSend = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: mockDdbSend })) }, + QueryCommand: jest.fn((input: unknown) => ({ _type: 'Query', input })), +})); +jest.mock('../../src/handlers/shared/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +process.env.REGISTRY_ASSETS_TABLE_NAME = 'RegistryAssets'; + +import type { APIGatewayProxyEvent } from 'aws-lambda'; +import { handler as listHandler } from '../../src/handlers/registry-list'; +import { handler as showHandler } from '../../src/handlers/registry-show'; + +function row(namespace: string, name: string, version: string, status = 'approved') { + return { + pk: `mcp_server#${namespace}/${name}`, + sk: version, + kind: 'mcp_server', + namespace, + name, + version, + status, + created_at: '2026-07-20T00:00:00Z', + publisher: 'sub-1', + descriptor: { summary: 's', permissions: [] }, + status_history: [], + }; +} + +beforeEach(() => jest.clearAllMocks()); + +describe('registry list handler', () => { + function listEvent(qs: Record | null, authed = true): APIGatewayProxyEvent { + return { + queryStringParameters: qs, + requestContext: { authorizer: { claims: authed ? { sub: 'u' } : {} } }, + } as unknown as APIGatewayProxyEvent; + } + + test('401 unauthenticated', async () => { + expect((await listHandler(listEvent({ kind: 'mcp_server' }, false))).statusCode).toBe(401); + }); + + test('400 when kind missing or unknown', async () => { + expect((await listHandler(listEvent(null))).statusCode).toBe(400); + expect((await listHandler(listEvent({ kind: 'nope' }))).statusCode).toBe(400); + }); + + test('collapses versions to the highest per asset', async () => { + mockDdbSend.mockResolvedValue({ + Items: [row('acme', 'pdf', '1.0.0'), row('acme', 'pdf', '1.10.0'), row('acme', 'other', '2.0.0')], + }); + const res = await listHandler(listEvent({ kind: 'mcp_server' })); + expect(res.statusCode).toBe(200); + const assets = JSON.parse(res.body).data.assets; + expect(assets).toHaveLength(2); + const pdf = assets.find((a: { name: string }) => a.name === 'pdf'); + expect(pdf.latest_version).toBe('1.10.0'); // semver, not lexicographic + }); + + test('excludes removed unless status=removed requested', async () => { + mockDdbSend.mockResolvedValue({ Items: [row('acme', 'gone', '1.0.0', 'removed')] }); + expect(JSON.parse((await listHandler(listEvent({ kind: 'mcp_server' }))).body).data.assets).toHaveLength(0); + expect( + JSON.parse((await listHandler(listEvent({ kind: 'mcp_server', status: 'removed' }))).body).data.assets, + ).toHaveLength(1); + }); + + test('honors the namespace filter', async () => { + mockDdbSend.mockResolvedValue({ Items: [row('acme', 'pdf', '1.0.0'), row('other', 'pdf', '1.0.0')] }); + const res = await listHandler(listEvent({ kind: 'mcp_server', namespace: 'acme' })); + expect(JSON.parse(res.body).data.assets).toHaveLength(1); + }); +}); + +describe('registry show handler', () => { + function showEvent(params: Record | null, authed = true): APIGatewayProxyEvent { + return { + pathParameters: params, + requestContext: { authorizer: { claims: authed ? { sub: 'u' } : {} } }, + } as unknown as APIGatewayProxyEvent; + } + + test('401 unauthenticated', async () => { + expect( + (await showHandler(showEvent({ kind: 'mcp_server', namespace: 'acme', name: 'pdf' }, false))).statusCode, + ).toBe(401); + }); + + test('400 on missing path params', async () => { + expect((await showHandler(showEvent(null))).statusCode).toBe(400); + }); + + test('404 when the asset has no versions', async () => { + mockDdbSend.mockResolvedValue({ Items: [] }); + const res = await showHandler(showEvent({ kind: 'mcp_server', namespace: 'acme', name: 'pdf' })); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body).error.code).toBe('REGISTRY_ASSET_NOT_FOUND'); + }); + + test('returns versions newest-semver-first', async () => { + mockDdbSend.mockResolvedValue({ + Items: [row('acme', 'pdf', '1.2.0'), row('acme', 'pdf', '1.10.0'), row('acme', 'pdf', '1.9.0')], + }); + const res = await showHandler(showEvent({ kind: 'mcp_server', namespace: 'acme', name: 'pdf' })); + expect(res.statusCode).toBe(200); + const versions = JSON.parse(res.body).data.versions.map((v: { version: string }) => v.version); + expect(versions).toEqual(['1.10.0', '1.9.0', '1.2.0']); + }); +}); diff --git a/cdk/test/handlers/registry-publish.test.ts b/cdk/test/handlers/registry-publish.test.ts new file mode 100644 index 000000000..60c52fa48 --- /dev/null +++ b/cdk/test/handlers/registry-publish.test.ts @@ -0,0 +1,155 @@ +/** + * 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. + */ + +const mockDdbSend = jest.fn(); +const mockS3Send = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: mockDdbSend })) }, + PutCommand: jest.fn((input: unknown) => ({ _type: 'Put', input })), +})); +jest.mock('@aws-sdk/client-s3', () => ({ + S3Client: jest.fn(() => ({ send: mockS3Send })), + PutObjectCommand: jest.fn((input: unknown) => ({ _type: 'PutObject', input })), +})); +jest.mock('../../src/handlers/shared/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +process.env.REGISTRY_ASSETS_TABLE_NAME = 'RegistryAssets'; +process.env.REGISTRY_ARTIFACTS_BUCKET_NAME = 'registry-artifacts'; + +import type { APIGatewayProxyEvent } from 'aws-lambda'; +import { handler } from '../../src/handlers/registry-publish'; + +function event(opts: { + groups?: string[]; + body?: unknown; + authed?: boolean; + autoApprove?: boolean; +}): APIGatewayProxyEvent { + const claims: Record = {}; + if (opts.authed !== false) claims.sub = 'user-123'; + if (opts.groups) claims['cognito:groups'] = opts.groups; + return { + body: opts.body === undefined ? null : JSON.stringify(opts.body), + queryStringParameters: opts.autoApprove ? { auto_approve: 'true' } : null, + requestContext: { authorizer: { claims } }, + } as unknown as APIGatewayProxyEvent; +} + +const validBody = { + kind: 'mcp_server', + namespace: 'acme', + name: 'pdf-tools', + version: '1.4.1', + descriptor: { summary: 'PDF', permissions: [], transport: 'http', tool_prefix: 'mcp__pdf__' }, + artifact_b64: Buffer.from('{}').toString('base64'), +}; + +beforeEach(() => { + jest.clearAllMocks(); + mockS3Send.mockResolvedValue({}); + mockDdbSend.mockResolvedValue({}); +}); + +describe('registry publish handler', () => { + test('401 when unauthenticated', async () => { + const res = await handler(event({ authed: false, body: validBody })); + expect(res.statusCode).toBe(401); + }); + + test('403 when caller is in no registry group', async () => { + const res = await handler(event({ groups: ['SomeOtherGroup'], body: validBody })); + expect(res.statusCode).toBe(403); + }); + + test('publishes as submitted for a RegistryPublisher', async () => { + const res = await handler(event({ groups: ['RegistryPublisher'], body: validBody })); + expect(res.statusCode).toBe(201); + expect(JSON.parse(res.body).data.status).toBe('submitted'); + // artifact uploaded, then record written + expect(mockS3Send).toHaveBeenCalledTimes(1); + expect(mockDdbSend).toHaveBeenCalledTimes(1); + }); + + test('inline-content kind (cedar) publishes with NO S3 upload', async () => { + // #246 PR 3 regression: cedar/skill carry inline content and send no + // artifact_b64. The handler must NOT attempt an S3 upload (Buffer.from( + // undefined) โ†’ 500). It writes the DDB record only. + const cedarBody = { + kind: 'cedar_policy_module', + namespace: 'acme', + name: 'guard', + version: '1.0.0', + descriptor: { + summary: 'guard', + permissions: [], + cedar_actions: [], + cedar_text: 'forbid(principal, action, resource);', + }, + }; + const res = await handler(event({ groups: ['RegistryApprover'], body: cedarBody, autoApprove: true })); + expect(res.statusCode).toBe(201); + expect(mockS3Send).not.toHaveBeenCalled(); + expect(mockDdbSend).toHaveBeenCalledTimes(1); + }); + + test('auto_approve lands approved only for an approver', async () => { + const approver = await handler(event({ groups: ['RegistryApprover'], body: validBody, autoApprove: true })); + expect(JSON.parse(approver.body).data.status).toBe('approved'); + + // a plain publisher asking for auto_approve still lands submitted + const publisher = await handler(event({ groups: ['RegistryPublisher'], body: validBody, autoApprove: true })); + expect(JSON.parse(publisher.body).data.status).toBe('submitted'); + }); + + test('400 on descriptor validation failure', async () => { + const res = await handler( + event({ groups: ['RegistryPublisher'], body: { ...validBody, version: '^1.4.1' } }), + ); + expect(res.statusCode).toBe(400); + expect(mockDdbSend).not.toHaveBeenCalled(); + }); + + test('409 on version immutability collision', async () => { + mockDdbSend.mockRejectedValueOnce( + Object.assign(new Error('exists'), { name: 'ConditionalCheckFailedException' }), + ); + const res = await handler(event({ groups: ['RegistryPublisher'], body: validBody })); + expect(res.statusCode).toBe(409); + expect(JSON.parse(res.body).error.code).toBe('REGISTRY_VERSION_EXISTS'); + }); + + test('write is guarded by attribute_not_exists on pk and sk', async () => { + await handler(event({ groups: ['RegistryPublisher'], body: validBody })); + const putInput = mockDdbSend.mock.calls[0][0].input; + expect(putInput.ConditionExpression).toContain('attribute_not_exists(pk)'); + expect(putInput.ConditionExpression).toContain('attribute_not_exists(sk)'); + }); + + test('400 on non-JSON body', async () => { + const res = await handler({ + body: 'not json', + queryStringParameters: null, + requestContext: { authorizer: { claims: { 'sub': 'u', 'cognito:groups': ['RegistryPublisher'] } } }, + } as unknown as APIGatewayProxyEvent); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/cdk/test/handlers/registry-resolve.test.ts b/cdk/test/handlers/registry-resolve.test.ts new file mode 100644 index 000000000..cd32cfb64 --- /dev/null +++ b/cdk/test/handlers/registry-resolve.test.ts @@ -0,0 +1,111 @@ +/** + * 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. + */ + +const mockResolveRef = jest.fn(); +const mockGetSignedUrl = jest.fn(); + +jest.mock('../../src/handlers/shared/registry-resolver', () => { + const actual = jest.requireActual('../../src/handlers/shared/registry-resolver'); + return { + ...actual, + resolveRef: mockResolveRef, + }; +}); +jest.mock('@aws-sdk/client-s3', () => ({ + S3Client: jest.fn(() => ({})), + GetObjectCommand: jest.fn((input: unknown) => ({ _type: 'Get', input })), +})); +jest.mock('@aws-sdk/s3-request-presigner', () => ({ getSignedUrl: mockGetSignedUrl })); +jest.mock('../../src/handlers/shared/logger', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +process.env.REGISTRY_ARTIFACTS_BUCKET_NAME = 'registry-artifacts'; + +import type { APIGatewayProxyEvent } from 'aws-lambda'; +import { handler } from '../../src/handlers/registry-resolve'; +import { RegistryResolutionError } from '../../src/handlers/shared/registry-resolver'; + +function event(ref: string | null, authed = true): APIGatewayProxyEvent { + return { + queryStringParameters: ref === null ? null : { ref }, + requestContext: { authorizer: { claims: authed ? { sub: 'u' } : {} } }, + } as unknown as APIGatewayProxyEvent; +} + +beforeEach(() => { + jest.clearAllMocks(); + mockGetSignedUrl.mockResolvedValue('https://signed.example/artifact'); +}); + +describe('registry resolve handler', () => { + test('401 when unauthenticated', async () => { + const res = await handler(event('registry://mcp_server/acme/x@^1.0.0', false)); + expect(res.statusCode).toBe(401); + }); + + test('400 when ref query param is missing', async () => { + const res = await handler(event(null)); + expect(res.statusCode).toBe(400); + }); + + test('200 with a presigned artifact URL for an mcp_server', async () => { + mockResolveRef.mockResolvedValue({ + kind: 'mcp_server', + namespace: 'acme', + name: 'pdf-tools', + version: '1.4.1', + descriptor: { summary: 's', permissions: [] }, + warnings: [], + }); + const res = await handler(event('registry://mcp_server/acme/pdf-tools@^1.0.0')); + expect(res.statusCode).toBe(200); + const data = JSON.parse(res.body).data; + expect(data.version).toBe('1.4.1'); + expect(data.artifact_url).toBe('https://signed.example/artifact'); + expect(mockGetSignedUrl).toHaveBeenCalledTimes(1); + }); + + test('422 with the specific reason on resolution failure', async () => { + mockResolveRef.mockRejectedValue( + new RegistryResolutionError('NO_MATCHING_VERSION', 'registry://mcp_server/acme/x@^9.0.0'), + ); + const res = await handler(event('registry://mcp_server/acme/x@^9.0.0')); + expect(res.statusCode).toBe(422); + const err = JSON.parse(res.body).error; + expect(err.code).toBe('REGISTRY_RESOLUTION_FAILED'); + expect(err.message).toContain('NO_MATCHING_VERSION'); + }); + + test('does not presign for a kind without an artifact loader path', async () => { + // capability is not in KINDS_REQUIRING_ARTIFACT + mockResolveRef.mockResolvedValue({ + kind: 'capability', + namespace: 'acme', + name: 'wf', + version: '1.0.0', + descriptor: { summary: 's', permissions: [] }, + warnings: [], + }); + const res = await handler(event('registry://capability/acme/wf@^1.0.0')); + expect(res.statusCode).toBe(200); + expect(mockGetSignedUrl).not.toHaveBeenCalled(); + expect(JSON.parse(res.body).data.artifact_url).toBeUndefined(); + }); +}); diff --git a/cdk/test/handlers/shared/orchestrator-registry.test.ts b/cdk/test/handlers/shared/orchestrator-registry.test.ts new file mode 100644 index 000000000..40463c9ae --- /dev/null +++ b/cdk/test/handlers/shared/orchestrator-registry.test.ts @@ -0,0 +1,180 @@ +/** + * 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. + */ + +const mockDdbSend = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); +jest.mock('@aws-sdk/client-s3', () => ({ S3Client: jest.fn(() => ({})) })); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: mockDdbSend })) }, + GetCommand: jest.fn((input: unknown) => ({ _type: 'Get', input })), + PutCommand: jest.fn((input: unknown) => ({ _type: 'Put', input })), + UpdateCommand: jest.fn((input: unknown) => ({ _type: 'Update', input })), +})); + +const mockResolveAll = jest.fn(); +jest.mock('../../../src/handlers/shared/registry-resolver', () => { + const actual = jest.requireActual('../../../src/handlers/shared/registry-resolver'); + return { ...actual, resolveAll: mockResolveAll }; +}); + +process.env.TASK_TABLE_NAME = 'Tasks'; +process.env.TASK_EVENTS_TABLE_NAME = 'TaskEvents'; +process.env.USER_CONCURRENCY_TABLE_NAME = 'UserConcurrency'; +process.env.RUNTIME_ARN = 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/t'; + +import { resolveRegistryAssetsForTask } from '../../../src/handlers/shared/orchestrator'; +import { RegistryResolutionError } from '../../../src/handlers/shared/registry-resolver'; +import type { BlueprintConfig } from '../../../src/handlers/shared/repo-config'; +import type { ResolvedAsset, TaskRecord } from '../../../src/handlers/shared/types'; + +const task = { task_id: 'T1', user_id: 'u', status: 'HYDRATING', branch_name: 'b' } as TaskRecord; + +function mcpAsset(name: string, version: string, warnings: string[] = []): ResolvedAsset { + return { + kind: 'mcp_server', + namespace: 'acme', + name, + version, + descriptor: { summary: 's', permissions: [] }, + warnings, + }; +} + +function cedarAsset(name: string, version: string, content: string): ResolvedAsset { + return { + kind: 'cedar_policy_module', + namespace: 'acme', + name, + version, + descriptor: { summary: 's', permissions: [] }, + content, + warnings: [], + }; +} + +function skillAsset(name: string, version: string, content: string): ResolvedAsset { + return { + kind: 'skill', + namespace: 'acme', + name, + version, + descriptor: { summary: 's', permissions: [] }, + content, + warnings: [], + }; +} + +beforeEach(() => { + jest.clearAllMocks(); + mockDdbSend.mockResolvedValue({}); +}); + +describe('resolveRegistryAssetsForTask', () => { + test('no pins โ†’ empty bundle, no resolve, no DDB write', async () => { + const bundle = await resolveRegistryAssetsForTask(task, {} as BlueprintConfig); + expect(bundle).toEqual({ mcp_servers: [], cedar_policy_modules: [], skills: [] }); + expect(mockResolveAll).not.toHaveBeenCalled(); + expect(mockDdbSend).not.toHaveBeenCalled(); + }); + + test('one MCP pin โ†’ resolves, stamps resolved_assets triples on the task', async () => { + mockResolveAll.mockResolvedValue({ + mcp_servers: [mcpAsset('pdf-tools', '1.4.1')], + cedar_policy_modules: [], + skills: [], + }); + const bundle = await resolveRegistryAssetsForTask(task, { + mcp_servers: ['registry://mcp_server/acme/pdf-tools@^1.0.0'], + } as BlueprintConfig); + + expect(bundle.mcp_servers).toHaveLength(1); + + // Find the Update that stamped resolved_assets. + const stamp = mockDdbSend.mock.calls + .map((c) => c[0].input) + .find((i) => i?.ExpressionAttributeNames?.['#ra'] === 'resolved_assets'); + expect(stamp).toBeDefined(); + expect(stamp.ExpressionAttributeValues[':ra']).toEqual([ + { kind: 'mcp_server', id: 'acme/pdf-tools', version: '1.4.1' }, + ]); + }); + + test('a bad pin propagates RegistryResolutionError (fail-closed) + emits failure event', async () => { + mockResolveAll.mockRejectedValue( + new RegistryResolutionError('NO_MATCHING_VERSION', 'registry://mcp_server/acme/x@^9.0.0'), + ); + await expect( + resolveRegistryAssetsForTask(task, { + mcp_servers: ['registry://mcp_server/acme/x@^9.0.0'], + } as BlueprintConfig), + ).rejects.toBeInstanceOf(RegistryResolutionError); + + // A registry_resolution_failed event row was written. + const failEvent = mockDdbSend.mock.calls + .map((c) => c[0].input) + .find((i) => JSON.stringify(i?.Item ?? {}).includes('registry_resolution_failed')); + expect(failEvent).toBeDefined(); + }); + + test('deprecated resolution still resolves and records the deprecation', async () => { + mockResolveAll.mockResolvedValue({ + mcp_servers: [mcpAsset('pdf-tools', '1.4.1', ['DEPRECATED'])], + cedar_policy_modules: [], + skills: [], + }); + const bundle = await resolveRegistryAssetsForTask(task, { + mcp_servers: ['registry://mcp_server/acme/pdf-tools@^1.0.0'], + } as BlueprintConfig); + expect(bundle.mcp_servers[0].warnings).toContain('DEPRECATED'); + // resolved event carries the deprecated list + const resolvedEvent = mockDdbSend.mock.calls + .map((c) => c[0].input) + .find((i) => JSON.stringify(i?.Item ?? {}).includes('registry_assets_resolved')); + expect(resolvedEvent).toBeDefined(); + }); + + test('collects all three ref kinds and stamps triples for each', async () => { + mockResolveAll.mockResolvedValue({ + mcp_servers: [mcpAsset('pdf-tools', '1.4.1')], + cedar_policy_modules: [cedarAsset('guard', '1.0.0', 'forbid(principal, action, resource);')], + skills: [skillAsset('refactor', '2.0.0', 'Preserve public APIs.')], + }); + await resolveRegistryAssetsForTask(task, { + mcp_servers: ['registry://mcp_server/acme/pdf-tools@^1.0.0'], + cedar_policy_modules: ['registry://cedar_policy_module/acme/guard@^1.0.0'], + skills: ['registry://skill/acme/refactor@^2.0.0'], + } as BlueprintConfig); + + // resolveAll got all three refs. + expect(mockResolveAll).toHaveBeenCalledWith([ + 'registry://mcp_server/acme/pdf-tools@^1.0.0', + 'registry://cedar_policy_module/acme/guard@^1.0.0', + 'registry://skill/acme/refactor@^2.0.0', + ]); + + const stamp = mockDdbSend.mock.calls + .map((c) => c[0].input) + .find((i) => i?.ExpressionAttributeNames?.['#ra'] === 'resolved_assets'); + expect(stamp.ExpressionAttributeValues[':ra']).toEqual([ + { kind: 'mcp_server', id: 'acme/pdf-tools', version: '1.4.1' }, + { kind: 'cedar_policy_module', id: 'acme/guard', version: '1.0.0' }, + { kind: 'skill', id: 'acme/refactor', version: '2.0.0' }, + ]); + }); +}); diff --git a/cdk/test/handlers/shared/registry-descriptor.test.ts b/cdk/test/handlers/shared/registry-descriptor.test.ts new file mode 100644 index 000000000..0f4d65a7d --- /dev/null +++ b/cdk/test/handlers/shared/registry-descriptor.test.ts @@ -0,0 +1,191 @@ +/** + * 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 { + artifactKey, + publishPk, + validatePublish, +} from '../../../src/handlers/shared/registry-descriptor'; + +const validMcp = { + kind: 'mcp_server', + namespace: 'acme', + name: 'pdf-tools', + version: '1.4.1', + descriptor: { summary: 'PDF tools', permissions: ['network:egress'], transport: 'http', tool_prefix: 'mcp__pdf__' }, + artifact_b64: Buffer.from('{}').toString('base64'), +}; + +describe('validatePublish', () => { + test('accepts a well-formed mcp_server publish', () => { + expect(validatePublish(validMcp)).toEqual([]); + }); + + test('accepts a cedar_policy_module with inline cedar_text', () => { + expect( + validatePublish({ + kind: 'cedar_policy_module', + namespace: 'acme', + name: 'guard', + version: '1.0.0', + descriptor: { + summary: 's', + permissions: [], + cedar_actions: ['Action::"ForcePush"'], + cedar_text: 'forbid(principal, action, resource);', + }, + }), + ).toEqual([]); + }); + + test('accepts a skill with inline prompt_fragment', () => { + expect( + validatePublish({ + kind: 'skill', + namespace: 'acme', + name: 'refactor', + version: '2.0.0', + descriptor: { + summary: 's', + permissions: [], + tool_hints: ['Edit'], + prompt_fragment: 'When refactoring, preserve public APIs.', + }, + }), + ).toEqual([]); + }); + + test('cedar_policy_module without cedar_text is rejected', () => { + const v = validatePublish({ + kind: 'cedar_policy_module', + namespace: 'acme', + name: 'guard', + version: '1.0.0', + descriptor: { summary: 's', permissions: [], cedar_actions: [] }, + }); + expect(v.some((x) => x.field === 'descriptor.cedar_text')).toBe(true); + }); + + test('skill prompt_fragment over the size cap is rejected', () => { + const v = validatePublish({ + kind: 'skill', + namespace: 'acme', + name: 'refactor', + version: '1.0.0', + descriptor: { + summary: 's', + permissions: [], + tool_hints: [], + prompt_fragment: 'x'.repeat(65_536 + 1), + }, + }); + expect(v.some((x) => x.field === 'descriptor.prompt_fragment')).toBe(true); + }); + + test('rejects a reserved kind (no loader in MVP)', () => { + const v = validatePublish({ ...validMcp, kind: 'plugin' }); + expect(v.some((x) => x.field === 'kind')).toBe(true); + }); + + test('rejects an unknown kind', () => { + const v = validatePublish({ ...validMcp, kind: 'nonsense' }); + expect(v.some((x) => x.field === 'kind')).toBe(true); + }); + + test('rejects a non-exact version (range is not a publish version)', () => { + expect(validatePublish({ ...validMcp, version: '^1.4.1' }).some((x) => x.field === 'version')).toBe(true); + expect(validatePublish({ ...validMcp, version: 'latest' }).some((x) => x.field === 'version')).toBe(true); + }); + + test('rejects a bad namespace / name', () => { + expect(validatePublish({ ...validMcp, namespace: 'Acme' }).some((x) => x.field === 'namespace')).toBe(true); + expect(validatePublish({ ...validMcp, name: '-bad' }).some((x) => x.field === 'name')).toBe(true); + }); + + test('requires descriptor summary and permissions', () => { + const v = validatePublish({ ...validMcp, descriptor: { transport: 'http', tool_prefix: 'x' } }); + expect(v.some((x) => x.field === 'descriptor.summary')).toBe(true); + expect(v.some((x) => x.field === 'descriptor.permissions')).toBe(true); + }); + + test('mcp_server requires transport and tool_prefix', () => { + const v = validatePublish({ + ...validMcp, + descriptor: { summary: 's', permissions: [] }, + }); + expect(v.some((x) => x.field === 'descriptor.transport')).toBe(true); + expect(v.some((x) => x.field === 'descriptor.tool_prefix')).toBe(true); + }); + + test('mcp_server with inline server_config does NOT require an artifact', () => { + const inlineNoArtifact = { + kind: 'mcp_server', + namespace: 'acme', + name: 'pdf-tools', + version: '1.4.1', + descriptor: { + summary: 'PDF tools', + permissions: [], + transport: 'http', + tool_prefix: 'mcp__pdf__', + server_config: { type: 'http', url: 'https://mcp.example.com/pdf' }, + }, + // no artifact_b64 โ€” the inline server_config is the loadable content + }; + expect(validatePublish(inlineNoArtifact)).toEqual([]); + }); + + test('mcp_server WITHOUT inline server_config OR artifact is rejected', () => { + const { artifact_b64: _omit, ...noArtifact } = validMcp; + // validMcp's descriptor has no server_config, so removing the artifact + // leaves nothing loadable โ†’ rejected. + expect(validatePublish(noArtifact).some((x) => x.field === 'artifact_b64')).toBe(true); + }); + + test('cedar_policy_module with inline cedar_text needs no artifact', () => { + // PR 3: cedar_text inline satisfies the loadable-content requirement, same + // as mcp_server's server_config โ€” no separate artifact needed. + const v = validatePublish({ + kind: 'cedar_policy_module', + namespace: 'acme', + name: 'guard', + version: '1.0.0', + descriptor: { + summary: 's', + permissions: [], + cedar_actions: ['Action::"X"'], + cedar_text: 'permit(principal, action, resource);', + }, + // no artifact_b64 โ€” inline cedar_text is the content + }); + expect(v).toEqual([]); + }); +}); + +describe('key helpers', () => { + test('publishPk builds {kind}#{namespace}/{name}', () => { + expect(publishPk('mcp_server', 'acme', 'pdf-tools')).toBe('mcp_server#acme/pdf-tools'); + }); + + test('artifactKey builds the versioned S3 key', () => { + expect(artifactKey('mcp_server', 'acme', 'pdf-tools', '1.4.1')).toBe( + 'mcp_server/acme/pdf-tools/1.4.1/artifact', + ); + }); +}); 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..d2d88f440 --- /dev/null +++ b/cdk/test/handlers/shared/registry-resolver.test.ts @@ -0,0 +1,316 @@ +/** + * 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 * as path from 'path'; +import { + isRegistryRef, + parseRef, + RegistryResolutionError, + resolveAll, + resolveRef, + type RegistryQueryClient, +} from '../../../src/handlers/shared/registry-resolver'; +import type { RegistryAssetRecord, RegistryAssetStatus } from '../../../src/handlers/shared/types'; + +// --- test helpers ------------------------------------------------------------ + +/** Build a minimal catalog row for the given version/status. */ +function row( + version: string, + status: RegistryAssetStatus, + overrides: Partial = {}, +): RegistryAssetRecord { + return { + pk: 'mcp_server#acme/pdf-tools', + sk: version, + kind: 'mcp_server', + namespace: 'acme', + name: 'pdf-tools', + version, + descriptor: { summary: 's', permissions: [] }, + artifact_ref: `mcp_server/acme/pdf-tools/${version}/artifact`, + status, + publisher: 'sub-1', + created_at: '2026-07-20T00:00:00Z', + status_history: [], + ...overrides, + }; +} + +/** A query client that returns the given rows for every query. */ +function clientReturning(rows: RegistryAssetRecord[]): RegistryQueryClient { + return { send: jest.fn().mockResolvedValue({ Items: rows }) }; +} + +const TABLE = 'RegistryAssets'; + +// --- parseRef / grammar ------------------------------------------------------ + +describe('parseRef', () => { + test('parses a caret-pinned snake_case kind', () => { + expect(parseRef('registry://mcp_server/acme/pdf-tools@^1.4.1')).toEqual({ + kind: 'mcp_server', + namespace: 'acme', + name: 'pdf-tools', + constraint: '^1.4.1', + }); + }); + + test.each([ + 'registry://mcp_server/acme/pdf-tools@1.4.1', + 'registry://cedar_policy_module/acme/guard@~2.0.0', + 'registry://skill/acme/refactor@^1.0.0', + 'registry://mcp_server/acme/pdf-tools@1.4.1-rc.1', + ])('accepts valid ref %s', (ref) => { + expect(() => parseRef(ref)).not.toThrow(); + expect(isRegistryRef(ref)).toBe(true); + }); + + test.each([ + ['legacy 2-segment', 'registry://skill/x-v1'], + ['hyphen kind (shipped-regex form)', 'registry://mcp-server/acme/pdf-tools'], + ['unpinned 3-segment', 'registry://mcp_server/acme/pdf-tools'], + ['floating latest', 'registry://mcp_server/acme/pdf-tools@latest'], + ['range operator', 'registry://mcp_server/acme/pdf-tools@>=1.0.0'], + ['wildcard', 'registry://mcp_server/acme/pdf-tools@1.x'], + ['wrong scheme', 'http://evil/acme/x@1.0.0'], + ['uppercase kind', 'registry://MCP_Server/acme/x@1.0.0'], + ])('rejects %s', (_label, ref) => { + expect(() => parseRef(ref)).toThrow(RegistryResolutionError); + expect(isRegistryRef(ref)).toBe(false); + }); + + test('rejects a syntactically-fine but unknown kind', () => { + expect(() => parseRef('registry://not_a_kind/acme/x@1.0.0')).toThrow(/unknown kind/); + }); + + test('parse failure carries INVALID_REGISTRY_REF', () => { + try { + parseRef('registry://skill/x-v1'); + throw new Error('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(RegistryResolutionError); + expect((e as RegistryResolutionError).reason).toBe('INVALID_REGISTRY_REF'); + } + }); +}); + +// --- resolveRef: semver + status -------------------------------------------- + +describe('resolveRef', () => { + test('selects the highest version satisfying a caret constraint', async () => { + const client = clientReturning([ + row('1.0.0', 'approved'), + row('1.2.0', 'approved'), + row('1.9.0', 'approved'), + row('2.0.0', 'approved'), // outside ^1.0.0 + ]); + const resolved = await resolveRef('registry://mcp_server/acme/pdf-tools@^1.0.0', { + client, + tableName: TABLE, + }); + expect(resolved.version).toBe('1.9.0'); + expect(resolved.warnings).toEqual([]); + }); + + test('ranks by semver, not lexicographically (1.10.0 > 1.9.0)', async () => { + const client = clientReturning([row('1.9.0', 'approved'), row('1.10.0', 'approved')]); + const resolved = await resolveRef('registry://mcp_server/acme/pdf-tools@^1.0.0', { + client, + tableName: TABLE, + }); + expect(resolved.version).toBe('1.10.0'); + }); + + test('tilde constraint stays within the minor', async () => { + const client = clientReturning([ + row('1.2.3', 'approved'), + row('1.2.9', 'approved'), + row('1.3.0', 'approved'), // outside ~1.2.3 + ]); + const resolved = await resolveRef('registry://mcp_server/acme/pdf-tools@~1.2.3', { + client, + tableName: TABLE, + }); + expect(resolved.version).toBe('1.2.9'); + }); + + test('exact constraint matches only that version', async () => { + const client = clientReturning([row('1.4.0', 'approved'), row('1.4.1', 'approved')]); + const resolved = await resolveRef('registry://mcp_server/acme/pdf-tools@1.4.0', { + client, + tableName: TABLE, + }); + expect(resolved.version).toBe('1.4.0'); + }); + + test('prereleases are excluded from a plain range', async () => { + const client = clientReturning([row('1.4.1-rc.1', 'approved'), row('1.4.0', 'approved')]); + const resolved = await resolveRef('registry://mcp_server/acme/pdf-tools@^1.0.0', { + client, + tableName: TABLE, + }); + expect(resolved.version).toBe('1.4.0'); + }); + + test('deprecated highest match resolves with a DEPRECATED warning', async () => { + const client = clientReturning([row('1.0.0', 'approved'), row('1.2.0', 'deprecated')]); + const resolved = await resolveRef('registry://mcp_server/acme/pdf-tools@^1.0.0', { + client, + tableName: TABLE, + }); + expect(resolved.version).toBe('1.2.0'); + expect(resolved.warnings).toEqual(['DEPRECATED']); + }); + + test('removed highest match fails with REMOVED', async () => { + const client = clientReturning([row('1.2.0', 'removed')]); + await expect( + resolveRef('registry://mcp_server/acme/pdf-tools@^1.0.0', { client, tableName: TABLE }), + ).rejects.toMatchObject({ reason: 'REMOVED' }); + }); + + test('submitted-only highest match fails with NO_MATCHING_VERSION', async () => { + const client = clientReturning([row('1.2.0', 'submitted')]); + await expect( + resolveRef('registry://mcp_server/acme/pdf-tools@^1.0.0', { client, tableName: TABLE }), + ).rejects.toMatchObject({ reason: 'NO_MATCHING_VERSION' }); + }); + + test('no version in range fails with NO_MATCHING_VERSION', async () => { + const client = clientReturning([row('2.0.0', 'approved')]); + await expect( + resolveRef('registry://mcp_server/acme/pdf-tools@^1.0.0', { client, tableName: TABLE }), + ).rejects.toMatchObject({ reason: 'NO_MATCHING_VERSION' }); + }); + + test('empty catalog fails with NO_MATCHING_VERSION', async () => { + const client = clientReturning([]); + await expect( + resolveRef('registry://mcp_server/acme/pdf-tools@^1.0.0', { client, tableName: TABLE }), + ).rejects.toMatchObject({ reason: 'NO_MATCHING_VERSION' }); + }); + + test('invalid ref never touches the catalog', async () => { + const send = jest.fn().mockResolvedValue({ Items: [] }); + const client: RegistryQueryClient = { send }; + await expect( + resolveRef('registry://skill/x-v1', { client, tableName: TABLE }), + ).rejects.toMatchObject({ reason: 'INVALID_REGISTRY_REF' }); + expect(send).not.toHaveBeenCalled(); + }); + + test('missing table name fails closed', async () => { + const client = clientReturning([row('1.0.0', 'approved')]); + await expect( + resolveRef('registry://mcp_server/acme/pdf-tools@^1.0.0', { client, tableName: '' }), + ).rejects.toBeInstanceOf(RegistryResolutionError); + }); +}); + +// --- resolveAll -------------------------------------------------------------- + +describe('resolveAll', () => { + test('groups resolved assets by kind', async () => { + const client: RegistryQueryClient = { + send: jest.fn((command: { input: { ExpressionAttributeValues: { ':pk': string } } }) => { + const pk = command.input.ExpressionAttributeValues[':pk']; + if (pk === 'mcp_server#acme/pdf-tools') { + return Promise.resolve({ Items: [row('1.0.0', 'approved')] }); + } + return Promise.resolve({ + Items: [ + { + ...row('2.0.0', 'approved'), + pk: 'skill#acme/refactor', + kind: 'skill', + namespace: 'acme', + name: 'refactor', + version: '2.0.0', + }, + ], + }); + }), + }; + const bundle = await resolveAll( + ['registry://mcp_server/acme/pdf-tools@^1.0.0', 'registry://skill/acme/refactor@^2.0.0'], + { client, tableName: TABLE }, + ); + expect(bundle.mcp_servers).toHaveLength(1); + expect(bundle.skills).toHaveLength(1); + expect(bundle.cedar_policy_modules).toHaveLength(0); + }); + + test('one bad ref rejects the whole bundle (fail-closed)', async () => { + const client = clientReturning([]); + await expect( + resolveAll( + ['registry://mcp_server/acme/pdf-tools@^1.0.0', 'registry://skill/x-v1'], + { client, tableName: TABLE }, + ), + ).rejects.toBeInstanceOf(RegistryResolutionError); + }); + + test('empty ref list yields an empty bundle', async () => { + const bundle = await resolveAll([], { tableName: TABLE }); + expect(bundle).toEqual({ mcp_servers: [], cedar_policy_modules: [], skills: [] }); + }); + + test('a resolvable but loader-less reserved kind is refused (fail-closed)', async () => { + // capability is a declared grammar kind with no MVP loader (REGISTRY.md ยง2). + // Even if the catalog somehow holds an approved row, resolveAll must refuse + // rather than drop it silently. + const client: RegistryQueryClient = { + send: jest.fn().mockResolvedValue({ + Items: [ + { + ...row('1.0.0', 'approved'), + pk: 'capability#acme/wf', + kind: 'capability', + namespace: 'acme', + name: 'wf', + }, + ], + }), + }; + await expect( + resolveAll(['registry://capability/acme/wf@^1.0.0'], { client, tableName: TABLE }), + ).rejects.toMatchObject({ reason: 'INVALID_REGISTRY_REF' }); + }); +}); + +// --- parity corpus (grammar side) ------------------------------------------- + +describe('registry-resolution grammar parity corpus', () => { + const corpusDir = path.resolve(__dirname, '../../../../contracts/registry-resolution'); + const files = fs.readdirSync(corpusDir).filter((f) => f.startsWith('grammar-') && f.endsWith('.json')); + + test('corpus dir is present and non-empty', () => { + expect(files.length).toBeGreaterThan(0); + }); + + test.each(files)('%s โ€” parseRef agrees with fixture verdict', (file) => { + const fixture = JSON.parse(fs.readFileSync(path.join(corpusDir, file), 'utf-8')); + expect(isRegistryRef(fixture.ref)).toBe(fixture.expected.valid); + if (fixture.expected.valid && fixture.expected.parsed) { + expect(parseRef(fixture.ref)).toEqual(fixture.expected.parsed); + } + }); +}); diff --git a/cdk/test/stacks/agent.test.ts b/cdk/test/stacks/agent.test.ts index 7c2610ee9..df21a3b58 100644 --- a/cdk/test/stacks/agent.test.ts +++ b/cdk/test/stacks/agent.test.ts @@ -44,8 +44,9 @@ describe('AgentStack', () => { // linear-workspace-registry (added in Phase 2.0b for OAuth bookkeeping), // jira-project-mapping, jira-user-mapping, jira-workspace-registry, // jira-webhook-dedup (added for the Jira Cloud integration), - // github-webhook-dedup (added by GitHubScreenshotIntegration on main) - template.resourceCountIs('AWS::DynamoDB::Table', 18); + // github-webhook-dedup (added by GitHubScreenshotIntegration on main), + // registry-assets (agent asset registry, #246) + template.resourceCountIs('AWS::DynamoDB::Table', 19); }); test('creates TaskApprovalsTable with user_id-status-index GSI', () => { diff --git a/cli/src/api-client.ts b/cli/src/api-client.ts index 7d908d9a1..c22c5ed86 100644 --- a/cli/src/api-client.ts +++ b/cli/src/api-client.ts @@ -39,6 +39,12 @@ import { LinearLinkResponse, NudgeRequest, NudgeResponse, + RegistryAssetKind, + RegistryListResponse, + RegistryPublishRequest, + RegistryPublishResponse, + RegistryShowResponse, + ResolvedAsset, SlackLinkResponse, PaginatedResponse, ReplayBundle, @@ -404,6 +410,38 @@ export class ApiClient { return res.data; } + /** POST /registry/assets โ€” publish an asset version (#246). */ + async publishRegistryAsset(req: RegistryPublishRequest, autoApprove?: boolean): Promise { + const path = autoApprove ? '/registry/assets?auto_approve=true' : '/registry/assets'; + const res = await this.request>('POST', path, req); + return res.data; + } + + /** GET /registry/resolve?ref= โ€” resolve a registry ref to a pinned asset (#246). */ + async resolveRegistryRef(ref: string): Promise { + const res = await this.request>( + 'GET', + `/registry/resolve?ref=${encodeURIComponent(ref)}`, + ); + return res.data; + } + + /** GET /registry/assets?kind= โ€” list assets of a kind (#246). */ + async listRegistryAssets(kind: RegistryAssetKind, opts?: { namespace?: string; status?: string }): Promise { + const params = new URLSearchParams({ kind }); + if (opts?.namespace) params.set('namespace', opts.namespace); + if (opts?.status) params.set('status', opts.status); + const res = await this.request>('GET', `/registry/assets?${params.toString()}`); + return res.data; + } + + /** GET /registry/assets/:kind/:namespace/:name โ€” show all versions (#246). */ + async showRegistryAsset(kind: string, namespace: string, name: string): Promise { + const path = `/registry/assets/${encodeURIComponent(kind)}/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`; + const res = await this.request>('GET', path); + return res.data; + } + /** POST /webhooks โ€” create a new webhook. */ async createWebhook(req: CreateWebhookRequest): Promise { const res = await this.request>('POST', '/webhooks', req); diff --git a/cli/src/bin/bgagent.ts b/cli/src/bin/bgagent.ts index 5faf408f3..efd48d107 100644 --- a/cli/src/bin/bgagent.ts +++ b/cli/src/bin/bgagent.ts @@ -36,6 +36,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'; @@ -87,6 +88,7 @@ program.addCommand(makeOpsCommand()); program.addCommand(makeWatchCommand()); program.addCommand(makeTraceCommand()); program.addCommand(makeWebhookCommand()); +program.addCommand(makeRegistryCommand()); program.addCommand(makeAdminCommand()); // Execute the CLI only when run directly. Importing this module (e.g. diff --git a/cli/src/commands/registry.ts b/cli/src/commands/registry.ts new file mode 100644 index 000000000..3b2316214 --- /dev/null +++ b/cli/src/commands/registry.ts @@ -0,0 +1,186 @@ +/** + * 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 { formatJson } from '../format'; +import type { RegistryAssetKind, RegistryDescriptor } from '../types'; + +/** Known asset kinds for CLI-side validation before hitting the API. */ +const KNOWN_KINDS: ReadonlySet = new Set([ + 'mcp_server', + 'cedar_policy_module', + 'skill', + 'plugin', + 'subagent', + 'prompt_fragment', + 'capability', +]); + +/** + * Build the ``bgagent registry`` command group (#246): publish, resolve, list, + * show. See docs/design/REGISTRY.md ยง4. + */ +export function makeRegistryCommand(): Command { + const registry = new Command('registry') + .description('Manage agent asset registry entries (MCP servers, Cedar modules, skills)'); + + registry.addCommand( + new Command('publish') + .description('Publish a new asset version') + .requiredOption('--kind ', 'Asset kind (e.g. mcp_server)') + .requiredOption('--namespace ', 'Asset namespace (e.g. acme)') + .requiredOption('--name ', 'Asset name (e.g. pdf-tools)') + // NOT ``--version``: that collides with commander's global version flag + // (program.version()), which prints the CLI version and exits before the + // action runs. Use ``--asset-version`` for the semver being published. + .requiredOption('--asset-version ', 'Exact semver version (e.g. 1.4.1)') + .requiredOption('--descriptor ', 'Path to a JSON descriptor file') + .option('--artifact ', 'Path to the artifact file (required for loadable kinds)') + .option('--auto-approve', 'Publish directly as approved (requires RegistryApprover)') + .option('--output ', 'Output format (text or json)', 'text') + .action(async (opts) => { + if (!KNOWN_KINDS.has(opts.kind)) { + throw new CliError(`Unknown asset kind '${opts.kind}'.`); + } + // The server is the authority on descriptor shape (it validates every + // required per-kind field at publish); the CLI forwards it verbatim. + const descriptor = readJsonFile(opts.descriptor, 'descriptor') as unknown as RegistryDescriptor; + const artifactB64 = opts.artifact + ? fs.readFileSync(opts.artifact).toString('base64') + : undefined; + + const client = new ApiClient(); + const result = await client.publishRegistryAsset( + { + kind: opts.kind, + namespace: opts.namespace, + name: opts.name, + version: opts.assetVersion, + descriptor, + ...(artifactB64 !== undefined && { artifact_b64: artifactB64 }), + }, + opts.autoApprove, + ); + + if (opts.output === 'json') { + console.log(formatJson(result)); + } else { + console.log( + `Published ${result.kind}/${result.namespace}/${result.name}@${result.version} ` + + `(status: ${result.status})`, + ); + } + }), + ); + + registry.addCommand( + new Command('resolve') + .description('Resolve a registry ref to a pinned version') + .argument('', 'registry://kind/namespace/name@constraint') + .option('--output ', 'Output format (text or json)', 'text') + .action(async (ref: string, opts) => { + const client = new ApiClient(); + const result = await client.resolveRegistryRef(ref); + if (opts.output === 'json') { + console.log(formatJson(result)); + } else { + const warn = result.warnings.length > 0 ? ` [${result.warnings.join(', ')}]` : ''; + console.log( + `${result.kind}/${result.namespace}/${result.name} โ†’ ${result.version}${warn}`, + ); + if (result.artifact_url) { + console.log(`artifact: ${result.artifact_url}`); + } + } + }), + ); + + registry.addCommand( + new Command('list') + .description('List assets of a kind') + .requiredOption('--kind ', 'Asset kind to list') + .option('--namespace ', 'Filter by namespace') + .option('--status ', 'Filter by status (e.g. removed)') + .option('--output ', 'Output format (text or json)', 'text') + .action(async (opts) => { + if (!KNOWN_KINDS.has(opts.kind)) { + throw new CliError(`Unknown asset kind '${opts.kind}'.`); + } + const client = new ApiClient(); + const result = await client.listRegistryAssets(opts.kind, { + namespace: opts.namespace, + status: opts.status, + }); + if (opts.output === 'json') { + console.log(formatJson(result)); + } else if (result.assets.length === 0) { + console.log('(no assets)'); + } else { + for (const a of result.assets) { + console.log(`${a.kind}/${a.namespace}/${a.name}\t${a.latest_version}\t${a.status}`); + } + } + }), + ); + + registry.addCommand( + new Command('show') + .description('Show all versions of a single asset') + .argument('', 'kind/namespace/name (e.g. mcp_server/acme/pdf-tools)') + .option('--output ', 'Output format (text or json)', 'text') + .action(async (id: string, opts) => { + const ID_SEGMENTS = 3; // kind/namespace/name + const parts = id.split('/'); + if (parts.length !== ID_SEGMENTS) { + throw new CliError(`Invalid asset id '${id}'. Expected 'kind/namespace/name'.`); + } + const [kind, namespace, name] = parts; + const client = new ApiClient(); + const result = await client.showRegistryAsset(kind, namespace, name); + if (opts.output === 'json') { + console.log(formatJson(result)); + } else { + console.log(`${result.kind}/${result.namespace}/${result.name}`); + for (const v of result.versions) { + console.log(` ${v.version}\t${v.status}\t${v.created_at}`); + } + } + }), + ); + + return registry; +} + +/** Read + parse a JSON file, raising a CliError with a clear message on failure. */ +function readJsonFile(path: string, label: string): Record { + let raw: string; + try { + raw = fs.readFileSync(path, 'utf-8'); + } catch { + throw new CliError(`Could not read ${label} file: ${path}`); + } + try { + return JSON.parse(raw) as Record; + } catch { + throw new CliError(`${label} file is not valid JSON: ${path}`); + } +} diff --git a/cli/src/types.ts b/cli/src/types.ts index b0a9f3440..da53a87bb 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -30,6 +30,160 @@ export type ResolvedWorkflow = { readonly version: string; }; +// --- Agent asset registry (#246) -------------------------------------------- +// Wire-facing types consumed by the ``bgagent registry`` commands. Mirror +// ``cdk/src/handlers/shared/types.ts`` per the CLI types-sync contract. The DDB +// record shape (``RegistryAssetRecord``) and audit-event shape are server-only +// and intentionally not mirrored here. See docs/design/REGISTRY.md / ADR-018. + +/** + * Registry asset kinds. Mirrors + * ``cdk/src/handlers/shared/types.ts::RegistryAssetKind``. + */ +export type RegistryAssetKind = + | 'mcp_server' + | 'cedar_policy_module' + | 'skill' + | 'plugin' + | 'subagent' + | 'prompt_fragment' + | 'capability'; + +/** + * Lifecycle status of a registry asset version. ``approved`` is the single + * canonical resolvable state ("active" is not a code value). Mirrors + * ``cdk/src/handlers/shared/types.ts::RegistryAssetStatus``. + */ +export type RegistryAssetStatus = + | 'draft' + | 'submitted' + | 'approved' + | 'rejected' + | 'deprecated' + | 'removed'; + +/** + * A parsed ``registry://kind/namespace/name@constraint`` reference; the + * constraint pin is mandatory. Mirrors + * ``cdk/src/handlers/shared/types.ts::RegistryRef``. + */ +export type RegistryRef = { + readonly kind: RegistryAssetKind; + readonly namespace: string; + readonly name: string; + readonly constraint: string; +}; + +/** + * Typed per-kind descriptor (validated at publish). Mirrors + * ``cdk/src/handlers/shared/types.ts::RegistryDescriptor``. + */ +export interface RegistryDescriptor { + readonly summary: string; + readonly permissions: readonly string[]; + readonly [key: string]: unknown; +} + +/** + * Result of resolving one ``registry://`` ref. Mirrors + * ``cdk/src/handlers/shared/types.ts::ResolvedAsset``. + */ +export interface ResolvedAsset { + readonly kind: RegistryAssetKind; + readonly namespace: string; + readonly name: string; + readonly version: string; + readonly descriptor: RegistryDescriptor; + /** + * The loadable text content of the asset โ€” Cedar policy text for a + * ``cedar_policy_module``, the prompt fragment for a ``skill``. Substrate- + * agnostic on purpose: the resolver populates it (MVP reads it inline from + * the descriptor; a future AgentCore/S3 substrate would fetch it) so callers + * never depend on WHERE the bytes live. Absent for kinds with no text body + * (e.g. ``mcp_server``, whose config rides in the descriptor's server_config). + */ + readonly content?: string; + readonly artifact_url?: string; + readonly warnings: readonly string[]; +} + +/** + * All of a task's resolved assets grouped by kind. Mirrors + * ``cdk/src/handlers/shared/types.ts::ResolvedAssetBundle``. + */ +export interface ResolvedAssetBundle { + readonly mcp_servers: readonly ResolvedAsset[]; + readonly cedar_policy_modules: readonly ResolvedAsset[]; + readonly skills: readonly ResolvedAsset[]; +} + +/** + * Compact ``{kind, id, version}`` audit triple stamped on a task. Mirrors + * ``cdk/src/handlers/shared/types.ts::ResolvedAssetSummary``. + */ +export interface ResolvedAssetSummary { + readonly kind: RegistryAssetKind; + readonly id: string; + readonly version: string; +} + +// --- Registry command request/response envelopes (#246) ---------------------- +// CLI-facing shapes for the `bgagent registry` commands. The server returns +// inline objects (not named types), so these are CLI-local (allowlisted in +// scripts/check-types-sync.ts). They describe the publish/list/show responses. + +/** Request body for ``bgagent registry publish`` (POST /registry/assets). */ +export interface RegistryPublishRequest { + readonly kind: RegistryAssetKind; + readonly namespace: string; + readonly name: string; + readonly version: string; + readonly descriptor: RegistryDescriptor; + /** Base64-encoded artifact bytes; required for kinds that carry an artifact. */ + readonly artifact_b64?: string; +} + +/** Response from a successful publish (subset of the created record). */ +export interface RegistryPublishResponse { + readonly kind: RegistryAssetKind; + readonly namespace: string; + readonly name: string; + readonly version: string; + readonly status: RegistryAssetStatus; + readonly artifact_ref?: string; + readonly created_at: string; +} + +/** One row in the list response โ€” the highest version per asset. */ +export interface RegistryAssetListItem { + readonly kind: RegistryAssetKind; + readonly namespace: string; + readonly name: string; + readonly latest_version: string; + readonly status: RegistryAssetStatus; +} + +/** Response from ``bgagent registry list`` (GET /registry/assets?kind=). */ +export interface RegistryListResponse { + readonly assets: readonly RegistryAssetListItem[]; +} + +/** One version row in the show response. */ +export interface RegistryVersionItem { + readonly version: string; + readonly status: RegistryAssetStatus; + readonly created_at: string; + readonly publisher: string; +} + +/** Response from ``bgagent registry show`` (GET /registry/assets/:kind/:ns/:name). */ +export interface RegistryShowResponse { + readonly kind: RegistryAssetKind; + readonly namespace: string; + readonly name: string; + readonly versions: readonly RegistryVersionItem[]; +} + /** Shared across all attachment interfaces. Add new types here (e.g., 'audio'). */ export type AttachmentType = 'image' | 'file' | 'url'; @@ -90,6 +244,9 @@ export interface TaskDetail { readonly repo: string | null; readonly issue_number: number | null; readonly resolved_workflow: ResolvedWorkflow | null; + /** Registry assets (#246) resolved for this task, or ``null`` when none + * were pinned. Mirrors ``cdk/src/handlers/shared/types.ts``. */ + readonly resolved_assets: ResolvedAssetSummary[] | null; readonly pr_number: number | null; readonly task_description: string | null; readonly branch_name: string; @@ -231,6 +388,9 @@ export interface TaskSummary { readonly repo: string | null; readonly issue_number: number | null; readonly resolved_workflow: ResolvedWorkflow | null; + /** Registry assets (#246) resolved for this task, or ``null`` when none + * were pinned. Mirrors ``cdk/src/handlers/shared/types.ts``. */ + readonly resolved_assets: ResolvedAssetSummary[] | null; readonly pr_number: number | null; readonly task_description: string | null; readonly branch_name: string; diff --git a/cli/test/commands/registry.test.ts b/cli/test/commands/registry.test.ts new file mode 100644 index 000000000..dbfc0567f --- /dev/null +++ b/cli/test/commands/registry.test.ts @@ -0,0 +1,170 @@ +/** + * 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 { ApiClient } from '../../src/api-client'; +import { makeRegistryCommand } from '../../src/commands/registry'; +import { CliError } from '../../src/errors'; + +jest.mock('../../src/api-client'); +jest.mock('fs'); + +const mockReadFileSync = fs.readFileSync as jest.MockedFunction; + +describe('registry command', () => { + let consoleSpy: jest.SpiedFunction; + const mockPublish = jest.fn(); + const mockResolve = jest.fn(); + const mockList = jest.fn(); + const mockShow = jest.fn(); + + beforeEach(() => { + consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + mockReadFileSync.mockReset(); + [mockPublish, mockResolve, mockList, mockShow].forEach((m) => m.mockReset()); + (ApiClient as jest.MockedClass).mockImplementation(() => ({ + publishRegistryAsset: mockPublish, + resolveRegistryRef: mockResolve, + listRegistryAssets: mockList, + showRegistryAsset: mockShow, + }) as unknown as ApiClient); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + jest.restoreAllMocks(); + }); + + describe('publish', () => { + test('reads descriptor + artifact and calls the API', async () => { + mockReadFileSync.mockImplementation(((p: fs.PathOrFileDescriptor) => { + if (String(p).includes('descriptor')) { + return JSON.stringify({ summary: 's', permissions: [], transport: 'http', tool_prefix: 'x' }); + } + return Buffer.from('{"url":"u"}'); + }) as typeof fs.readFileSync); + mockPublish.mockResolvedValue({ + kind: 'mcp_server', + namespace: 'acme', + name: 'pdf', + version: '1.4.1', + status: 'submitted', + created_at: '2026-07-20T00:00:00Z', + }); + + const cmd = makeRegistryCommand(); + await cmd.parseAsync([ + 'node', 'test', 'publish', + '--kind', 'mcp_server', '--namespace', 'acme', '--name', 'pdf', '--asset-version', '1.4.1', + '--descriptor', '/tmp/descriptor.json', '--artifact', '/tmp/server.json', + ]); + + expect(mockPublish).toHaveBeenCalledTimes(1); + const [req, autoApprove] = mockPublish.mock.calls[0]; + expect(req.kind).toBe('mcp_server'); + expect(req.version).toBe('1.4.1'); + expect(req.artifact_b64).toBe(Buffer.from('{"url":"u"}').toString('base64')); + expect(autoApprove).toBeFalsy(); + }); + + test('passes auto_approve when --auto-approve is set', async () => { + mockReadFileSync.mockReturnValue( + JSON.stringify({ summary: 's', permissions: [], transport: 'http', tool_prefix: 'x' }), + ); + mockPublish.mockResolvedValue({ + kind: 'mcp_server', + namespace: 'acme', + name: 'pdf', + version: '1.0.0', + status: 'approved', + created_at: 'now', + }); + const cmd = makeRegistryCommand(); + await cmd.parseAsync([ + 'node', 'test', 'publish', + '--kind', 'mcp_server', '--namespace', 'acme', '--name', 'pdf', '--asset-version', '1.0.0', + '--descriptor', '/tmp/d.json', '--auto-approve', + ]); + expect(mockPublish.mock.calls[0][1]).toBe(true); + }); + + test('rejects an unknown kind before hitting the API', async () => { + const cmd = makeRegistryCommand(); + await expect( + cmd.parseAsync([ + 'node', 'test', 'publish', + '--kind', 'nonsense', '--namespace', 'a', '--name', 'b', '--asset-version', '1.0.0', + '--descriptor', '/tmp/d.json', + ]), + ).rejects.toThrow(CliError); + expect(mockPublish).not.toHaveBeenCalled(); + }); + }); + + describe('resolve', () => { + test('resolves a ref and prints the pinned version', async () => { + mockResolve.mockResolvedValue({ + kind: 'mcp_server', + namespace: 'acme', + name: 'pdf', + version: '1.4.1', + descriptor: { summary: 's', permissions: [] }, + warnings: [], + }); + const cmd = makeRegistryCommand(); + await cmd.parseAsync(['node', 'test', 'resolve', 'registry://mcp_server/acme/pdf@^1.0.0']); + expect(mockResolve).toHaveBeenCalledWith('registry://mcp_server/acme/pdf@^1.0.0'); + expect(consoleSpy.mock.calls.flat().join('\n')).toContain('1.4.1'); + }); + }); + + describe('list', () => { + test('lists assets of a kind', async () => { + mockList.mockResolvedValue({ + assets: [{ kind: 'mcp_server', namespace: 'acme', name: 'pdf', latest_version: '1.4.1', status: 'approved' }], + }); + const cmd = makeRegistryCommand(); + await cmd.parseAsync(['node', 'test', 'list', '--kind', 'mcp_server']); + expect(mockList).toHaveBeenCalledWith('mcp_server', { namespace: undefined, status: undefined }); + expect(consoleSpy.mock.calls.flat().join('\n')).toContain('acme/pdf'); + }); + }); + + describe('show', () => { + test('parses kind/namespace/name and lists versions', async () => { + mockShow.mockResolvedValue({ + kind: 'mcp_server', + namespace: 'acme', + name: 'pdf', + versions: [{ version: '1.4.1', status: 'approved', created_at: 'now', publisher: 'u' }], + }); + const cmd = makeRegistryCommand(); + await cmd.parseAsync(['node', 'test', 'show', 'mcp_server/acme/pdf']); + expect(mockShow).toHaveBeenCalledWith('mcp_server', 'acme', 'pdf'); + }); + + test('rejects a malformed id', async () => { + const cmd = makeRegistryCommand(); + await expect( + cmd.parseAsync(['node', 'test', 'show', 'not-a-valid-id']), + ).rejects.toThrow(CliError); + expect(mockShow).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/contracts/registry-resolution/README.md b/contracts/registry-resolution/README.md new file mode 100644 index 000000000..0b212f816 --- /dev/null +++ b/contracts/registry-resolution/README.md @@ -0,0 +1,82 @@ +# Registry resolution parity fixtures + +Golden-file test vectors for the #246 registry URI grammar and resolution +semantics. Each fixture is a `registry://` ref (and, for resolution fixtures, a +catalog of candidate versions) paired with its **expected verdict**. + +**Design reference:** [`docs/design/REGISTRY.md`](../../docs/design/REGISTRY.md) +ยง6 (grammar) and ยง5 (resolution semantics); +[ADR-018](../../docs/decisions/ADR-018-agent-asset-registry.md). + +## Why this lives in `contracts/` + +A `registry://` ref is parsed on **two** sides of the platform in different +languages: the Python agent validator (`agent/src/workflow/validator.py` +`_REGISTRY_REF`, run at author/CI time) and the TypeScript resolver +(`cdk/src/handlers/shared/registry-resolver.ts` `parseRef`, run at the +create-task boundary). This is exactly the two-language drift hazard the repo +learned from twice โ€” Cedar bindings and workflow validation. This corpus is the +neutral agreement both implementations must reproduce; neither `agent/` nor the +registry service owns it. + +Mirrors [`contracts/cedar-parity/`](../cedar-parity/README.md) and +[`contracts/workflow-validation/`](../workflow-validation/README.md). + +## Fixture shape + +Two fixture families share one directory, distinguished by their top-level keys. + +### Grammar fixtures โ€” `grammar-*.json` + +Test the URI regex only (no catalog). Both `_REGISTRY_REF.match()` (Python) and +`parseRef()` (TypeScript) must agree on `valid`. + +```json +{ + "name": "short-identifier", + "description": "One-sentence purpose", + "ref": "registry://mcp_server/acme/pdf-tools@^1.4.1", + "expected": { "valid": true } +} +``` + +- `expected.valid` โ€” `true` iff the ref matches the grammar (REGISTRY.md ยง6). +- When `valid` is `true`, `expected.parsed` MAY give the decomposed + `{ kind, namespace, name, constraint }` the TS parser must return. + +### Resolution fixtures โ€” `resolve-*.json` + +Test the full resolve (grammar + semver match + status rules, REGISTRY.md ยง5) +against an in-memory catalog. + +```json +{ + "name": "short-identifier", + "description": "One-sentence purpose", + "ref": "registry://mcp_server/acme/pdf-tools@^1.0.0", + "catalog": [ + { "version": "1.0.0", "status": "approved" }, + { "version": "1.2.0", "status": "approved" }, + { "version": "2.0.0", "status": "approved" } + ], + "expected": { "version": "1.2.0", "warnings": [] } +} +``` + +- `expected.version` โ€” the version that must win, or `null` on failure. +- `expected.reason` โ€” required when `version` is `null`: one of + `NO_MATCHING_VERSION`, `REMOVED`, `INVALID_CONSTRAINT`, `INVALID_REGISTRY_REF`. +- `expected.warnings` โ€” e.g. `["DEPRECATED"]` when the winning version is + `deprecated`. + +## Consumers + +- **Agent (Python):** loads every `grammar-*.json` and asserts + `bool(_REGISTRY_REF.match(ref)) == expected.valid`. +- **Registry resolver (TypeScript):** loads every fixture; `parseRef` must agree + on grammar, and `resolveRef` against the fixture catalog must reproduce the + resolution verdict. + +If a fixture's `expected` no longer matches an implementation, CI fails before +the change ships โ€” either an implementation regressed or the fixture must be +updated as a recorded decision. diff --git a/contracts/registry-resolution/grammar-invalid-floating-latest.json b/contracts/registry-resolution/grammar-invalid-floating-latest.json new file mode 100644 index 000000000..d8036f601 --- /dev/null +++ b/contracts/registry-resolution/grammar-invalid-floating-latest.json @@ -0,0 +1,6 @@ +{ + "name": "grammar-invalid-floating-latest", + "description": "Floating 'latest' constraint is rejected (REGISTRY.md ยง5).", + "ref": "registry://mcp_server/acme/pdf-tools@latest", + "expected": { "valid": false } +} diff --git a/contracts/registry-resolution/grammar-invalid-floating-range.json b/contracts/registry-resolution/grammar-invalid-floating-range.json new file mode 100644 index 000000000..ea23392cf --- /dev/null +++ b/contracts/registry-resolution/grammar-invalid-floating-range.json @@ -0,0 +1,6 @@ +{ + "name": "grammar-invalid-floating-range", + "description": "Range operator '>=' is rejected โ€” only exact/caret/tilde allowed.", + "ref": "registry://mcp_server/acme/pdf-tools@>=1.0.0", + "expected": { "valid": false } +} diff --git a/contracts/registry-resolution/grammar-invalid-legacy-2segment.json b/contracts/registry-resolution/grammar-invalid-legacy-2segment.json new file mode 100644 index 000000000..0e9e088b2 --- /dev/null +++ b/contracts/registry-resolution/grammar-invalid-legacy-2segment.json @@ -0,0 +1,6 @@ +{ + "name": "grammar-invalid-legacy-2segment", + "description": "Legacy WORKFLOWS.md 2-segment form (registry://kind/name) โ€” no namespace, no pin. Superseded by the 3-segment ADR-018 grammar.", + "ref": "registry://skill/x-v1", + "expected": { "valid": false } +} diff --git a/contracts/registry-resolution/grammar-invalid-no-constraint.json b/contracts/registry-resolution/grammar-invalid-no-constraint.json new file mode 100644 index 000000000..9fda53778 --- /dev/null +++ b/contracts/registry-resolution/grammar-invalid-no-constraint.json @@ -0,0 +1,6 @@ +{ + "name": "grammar-invalid-no-constraint", + "description": "3-segment but unpinned โ€” pins are MANDATORY (ADR-018 sub-decision 6, fail-closed). Invalid grammar.", + "ref": "registry://mcp_server/acme/pdf-tools", + "expected": { "valid": false } +} diff --git a/contracts/registry-resolution/grammar-invalid-scheme.json b/contracts/registry-resolution/grammar-invalid-scheme.json new file mode 100644 index 000000000..c66211d17 --- /dev/null +++ b/contracts/registry-resolution/grammar-invalid-scheme.json @@ -0,0 +1,6 @@ +{ + "name": "grammar-invalid-scheme", + "description": "Non-registry scheme is rejected (rule-8 http://evil case).", + "ref": "http://evil/acme/x@1.0.0", + "expected": { "valid": false } +} diff --git a/contracts/registry-resolution/grammar-invalid-underscore-kind-legacy.json b/contracts/registry-resolution/grammar-invalid-underscore-kind-legacy.json new file mode 100644 index 000000000..2319d6cae --- /dev/null +++ b/contracts/registry-resolution/grammar-invalid-underscore-kind-legacy.json @@ -0,0 +1,6 @@ +{ + "name": "grammar-invalid-underscore-kind-legacy", + "description": "PR #548 review: the SHIPPED regex only matched hyphenated 'mcp-server'. That hyphen form is NOT the #246 grammar (kinds are snake_case) and carries no pin โ€” invalid.", + "ref": "registry://mcp-server/acme/pdf-tools", + "expected": { "valid": false } +} diff --git a/contracts/registry-resolution/grammar-invalid-uppercase-kind.json b/contracts/registry-resolution/grammar-invalid-uppercase-kind.json new file mode 100644 index 000000000..847d2472c --- /dev/null +++ b/contracts/registry-resolution/grammar-invalid-uppercase-kind.json @@ -0,0 +1,6 @@ +{ + "name": "grammar-invalid-uppercase-kind", + "description": "Kind must be lowercase snake_case; uppercase is rejected.", + "ref": "registry://MCP_Server/acme/pdf-tools@1.0.0", + "expected": { "valid": false } +} diff --git a/contracts/registry-resolution/grammar-invalid-wildcard.json b/contracts/registry-resolution/grammar-invalid-wildcard.json new file mode 100644 index 000000000..ca3b242d4 --- /dev/null +++ b/contracts/registry-resolution/grammar-invalid-wildcard.json @@ -0,0 +1,6 @@ +{ + "name": "grammar-invalid-wildcard", + "description": "Wildcard x-range is rejected.", + "ref": "registry://mcp_server/acme/pdf-tools@1.x", + "expected": { "valid": false } +} diff --git a/contracts/registry-resolution/grammar-valid-exact.json b/contracts/registry-resolution/grammar-valid-exact.json new file mode 100644 index 000000000..5aac72462 --- /dev/null +++ b/contracts/registry-resolution/grammar-valid-exact.json @@ -0,0 +1,6 @@ +{ + "name": "grammar-valid-exact", + "description": "Exact-version pin on a multi-word snake_case kind.", + "ref": "registry://cedar_policy_module/acme/force-push-guard@1.0.0", + "expected": { "valid": true, "parsed": { "kind": "cedar_policy_module", "namespace": "acme", "name": "force-push-guard", "constraint": "1.0.0" } } +} diff --git a/contracts/registry-resolution/grammar-valid-mcp-caret.json b/contracts/registry-resolution/grammar-valid-mcp-caret.json new file mode 100644 index 000000000..175da8f1e --- /dev/null +++ b/contracts/registry-resolution/grammar-valid-mcp-caret.json @@ -0,0 +1,6 @@ +{ + "name": "grammar-valid-mcp-caret", + "description": "Canonical snake_case kind with caret constraint โ€” the ADR-018 example that the SHIPPED regex rejected (PR #548 review). Must be valid.", + "ref": "registry://mcp_server/acme/pdf-tools@^1.4.1", + "expected": { "valid": true, "parsed": { "kind": "mcp_server", "namespace": "acme", "name": "pdf-tools", "constraint": "^1.4.1" } } +} diff --git a/contracts/registry-resolution/grammar-valid-prerelease.json b/contracts/registry-resolution/grammar-valid-prerelease.json new file mode 100644 index 000000000..1a7ec3067 --- /dev/null +++ b/contracts/registry-resolution/grammar-valid-prerelease.json @@ -0,0 +1,6 @@ +{ + "name": "grammar-valid-prerelease", + "description": "Exact pin to a prerelease version (REGISTRY.md ยง5 โ€” prereleases rank below their base).", + "ref": "registry://mcp_server/acme/pdf-tools@1.4.1-rc.1", + "expected": { "valid": true, "parsed": { "kind": "mcp_server", "namespace": "acme", "name": "pdf-tools", "constraint": "1.4.1-rc.1" } } +} diff --git a/contracts/registry-resolution/grammar-valid-skill-tilde.json b/contracts/registry-resolution/grammar-valid-skill-tilde.json new file mode 100644 index 000000000..d22073aa6 --- /dev/null +++ b/contracts/registry-resolution/grammar-valid-skill-tilde.json @@ -0,0 +1,6 @@ +{ + "name": "grammar-valid-skill-tilde", + "description": "skill kind with tilde constraint โ€” PR #548 review case 'registry://skill/acme/refactor@~2.0.0'.", + "ref": "registry://skill/acme/refactor@~2.0.0", + "expected": { "valid": true, "parsed": { "kind": "skill", "namespace": "acme", "name": "refactor", "constraint": "~2.0.0" } } +} diff --git a/contracts/workflow-validation/rule11-comment-outcome-s3-only-target.json b/contracts/workflow-validation/rule11-comment-outcome-s3-only-target.json index 6183053f0..378436c0d 100644 --- a/contracts/workflow-validation/rule11-comment-outcome-s3-only-target.json +++ b/contracts/workflow-validation/rule11-comment-outcome-s3-only-target.json @@ -22,7 +22,7 @@ "WebFetch" ], "mcp_servers": [ - "registry://mcp/web-search-v1" + "registry://mcp_server/abca/web-search@^1.0.0" ], "cedar_policy_modules": [ "builtin/hard_deny", diff --git a/contracts/workflow-validation/rule3-domain-default-repo-less-has-repo-steps.json b/contracts/workflow-validation/rule3-domain-default-repo-less-has-repo-steps.json index 2cc230976..cb23b92f7 100644 --- a/contracts/workflow-validation/rule3-domain-default-repo-less-has-repo-steps.json +++ b/contracts/workflow-validation/rule3-domain-default-repo-less-has-repo-steps.json @@ -23,14 +23,14 @@ "WebFetch" ], "mcp_servers": [ - "registry://mcp/web-search-v1" + "registry://mcp_server/abca/web-search@^1.0.0" ], "cedar_policy_modules": [ "builtin/hard_deny", "builtin/soft_deny" ], "skills": [ - "registry://skill/research-synthesis-v1" + "registry://skill/abca/research-synthesis@^1.0.0" ] }, "repo_config": { diff --git a/contracts/workflow-validation/rule7-repo-less-discover-and-provider.json b/contracts/workflow-validation/rule7-repo-less-discover-and-provider.json index d413321a7..6b5d506ac 100644 --- a/contracts/workflow-validation/rule7-repo-less-discover-and-provider.json +++ b/contracts/workflow-validation/rule7-repo-less-discover-and-provider.json @@ -22,14 +22,14 @@ "WebFetch" ], "mcp_servers": [ - "registry://mcp/web-search-v1" + "registry://mcp_server/abca/web-search@^1.0.0" ], "cedar_policy_modules": [ "builtin/hard_deny", "builtin/soft_deny" ], "skills": [ - "registry://skill/research-synthesis-v1" + "registry://skill/abca/research-synthesis@^1.0.0" ] }, "repo_config": { diff --git a/contracts/workflow-validation/valid-knowledge-domain-default-requires-repo.json b/contracts/workflow-validation/valid-knowledge-domain-default-requires-repo.json index 9fa35f287..41b562ae2 100644 --- a/contracts/workflow-validation/valid-knowledge-domain-default-requires-repo.json +++ b/contracts/workflow-validation/valid-knowledge-domain-default-requires-repo.json @@ -25,14 +25,14 @@ "WebFetch" ], "mcp_servers": [ - "registry://mcp/web-search-v1" + "registry://mcp_server/abca/web-search@^1.0.0" ], "cedar_policy_modules": [ "builtin/hard_deny", "builtin/soft_deny" ], "skills": [ - "registry://skill/research-synthesis-v1" + "registry://skill/abca/research-synthesis@^1.0.0" ] }, "repo_config": { diff --git a/contracts/workflow-validation/valid-knowledge-web-research.json b/contracts/workflow-validation/valid-knowledge-web-research.json index f040527c7..ed31a0920 100644 --- a/contracts/workflow-validation/valid-knowledge-web-research.json +++ b/contracts/workflow-validation/valid-knowledge-web-research.json @@ -26,14 +26,14 @@ "WebFetch" ], "mcp_servers": [ - "registry://mcp/web-search-v1" + "registry://mcp_server/abca/web-search@^1.0.0" ], "cedar_policy_modules": [ "builtin/hard_deny", "builtin/soft_deny" ], "skills": [ - "registry://skill/research-synthesis-v1" + "registry://skill/abca/research-synthesis@^1.0.0" ] }, "repo_config": { diff --git a/docs/design/DEPLOYMENT_ROLES.md b/docs/design/DEPLOYMENT_ROLES.md index 9f2f0baca..e7f17b666 100644 --- a/docs/design/DEPLOYMENT_ROLES.md +++ b/docs/design/DEPLOYMENT_ROLES.md @@ -391,6 +391,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", diff --git a/docs/design/REGISTRY.md b/docs/design/REGISTRY.md new file mode 100644 index 000000000..54a21a0ed --- /dev/null +++ b/docs/design/REGISTRY.md @@ -0,0 +1,244 @@ +# 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 storage schema (DynamoDB + S3), the publish/resolve/list/show API contract, resolution semantics (semver, immutability, status), and how a resolved bundle flows from orchestrator to agent. +- **Related docs:** [WORKFLOWS.md](./WORKFLOWS.md) for the `registry://` grammar and asset-kind vocabulary this generalizes, [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. +- **Decision record:** [ADR-018](../decisions/ADR-018-agent-asset-registry.md). +- **Tracking issue:** [#246](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/246). Child issues: [#478](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/478) (lifecycle), [#479](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/479) (versioning + immutability), [#480](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/480) (Blueprint integration + ACLs), [#481](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/481) (capability descriptors). + +> **Substrate.** ADR-018 prefers AWS Agent Registry (Bedrock AgentCore) but requires a design-time prototype and defines four fall-back conditions. This document specifies the **DynamoDB + S3 fallback** (ADR-018 substrate option 2), which the fall-back conditions already select at authoring time (AgentCore Registry is in public preview and hard-migrates namespaces on 2026-08-06). The `RegistryClient` seam (ยง8) keeps the AgentCore path open as a later swap without changing the contract. + +## 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 / Phase 3):** + +- Transitive dependencies between registry assets (explicitly disallowed in MVP). +- Plugin, subagent, prompt_fragment, and capability (= workflow) loaders โ€” declared in the schema, not loaded. +- Federation to / mirroring from upstream registries (official MCP registry, CNCF catalogs) โ€” see ยง11. +- Cedar-governed publish ACLs and per-namespace granularity โ€” MVP uses two Cognito groups (ยง10). +- EventBridge as a primary bus for asset events; migrating first-party workflows into the registry. + +## 2. Asset kinds for MVP + +| Kind | MVP status | Inline content | Applied by | +|------|-----------|----------------|-----------| +| `mcp_server` | **implemented E2E** | `server_config` (an `mcpServers` entry) | agent โ†’ merged into `.mcp.json` | +| `cedar_policy_module` | **implemented** | `cedar_text` (Cedar policy source) | orchestrator โ†’ merged into the `cedar_policies` payload (byte-identical to inline blueprint policies) โ†’ agent `PolicyEngine` | +| `skill` | **implemented** | `prompt_fragment` (+ `tool_hints`) | agent โ†’ appended to the system prompt | +| `plugin`, `subagent`, `prompt_fragment`, `capability` | **declared, not loaded** | โ€” | โ€” (reserved kinds; ยง12 of ADR-018) | + +`capability` is reserved for workflows (ADR-014 vocabulary); workflows do **not** migrate into the registry in this work (ADR-018 sub-decision 12). Reserved kinds are accepted by the grammar but publish rejects them until a loader ships. + +**Inline content + the `content` seam.** All three MVP kinds carry their loadable body **inline in the descriptor** (`server_config` / `cedar_text` / `prompt_fragment`), capped at 64 KiB at publish. The resolver exposes it on `ResolvedAsset.content` (for the text kinds) so callers never depend on *where* the bytes live โ€” swapping to an S3/AgentCore-backed fetch later (for larger assets or the preferred substrate) is confined to the resolver and invisible to the orchestrator and agent. + +**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 โ€” the cedar-parity contract holds by construction. **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. Schema + +### 3.1 `RegistryAssetsTable` (DynamoDB) + +Metadata records. One row per published `(kind, namespace, name, version)`. + +| Attribute | Type | Key | Notes | +|-----------|------|-----|-------| +| `pk` | S | **partition** | `{kind}#{namespace}/{name}` โ€” e.g. `mcp_server#acme/pdf-tools` | +| `sk` | S | **sort** | `version` โ€” e.g. `1.4.1` (semver string) | +| `kind` | S | | denormalized for the list GSI | +| `namespace` | S | | | +| `name` | S | | | +| `version` | S | | semver, immutable once written | +| `descriptor` | M | | typed per-kind descriptor (ยง3.3) โ€” validated at publish | +| `artifact_ref` | S | | S3 key (ยง3.4); empty for descriptor-only kinds | +| `status` | S | | `draft` \| `submitted` \| `approved` \| `rejected` \| `deprecated` \| `removed` (ยง5) | +| `publisher` | S | | Cognito `sub` of the publishing principal | +| `created_at` | S | | ISO-8601, set at publish | +| `status_history` | L | | append-only audit: `[{status, actor, at, rationale}]` | + +**GSI `kind-index`** โ€” partition `kind`, sort `pk` โ€” powers `GET /registry/assets?kind=mcp_server` (list) without a scan. + +> **Canonical status token.** The approved-and-resolvable state is spelled **`approved`** everywhere โ€” in DynamoDB, the TypeScript resolver, and the Python loader. ADR-018 prose mentions "`active`" as a synonym; that word is **not** a value in code. One token, byte-for-byte, across both languages (the parity hazard ADR-018's `(โˆ’)` bullet flags). + +### 3.2 Partition/sort rationale + +`pk = {kind}#{namespace}/{name}` groups every version of one asset under a single partition; `sk = version` orders them. `GET /registry/assets/{id}` (show all versions) is a single `Query` on `pk`. Resolution (`resolveRef`) is a `Query` on `pk` that returns all versions, then ranks client-side by parsed semver (ยง5) โ€” DynamoDB cannot sort semver lexicographically (`1.10.0` < `1.9.0` as strings), so ranking is always in code. + +### 3.3 Per-kind descriptor shapes + +Every record carries a typed `descriptor`, validated at publish (ยง4). Shared required fields: `summary` (string), `permissions` (list of strings). Per kind: + +```jsonc +// mcp_server +{ "summary": "...", "permissions": ["network:egress"], + "transport": "http" | "stdio", + "egress_domains": ["mcp.example.com"], // feeds Blueprint egress review + "tool_prefix": "mcp__example__", // tools surface under this prefix + "server_config": { /* mcpServers entry, or in artifact for large configs */ } } + +// cedar_policy_module +{ "summary": "...", "permissions": [], + "cedar_actions": ["Action::\"ForcePush\""], // actions the module introduces + "policy_text_ref": "artifact" } // Cedar text lives in the artifact + +// skill +{ "summary": "...", "permissions": [], + "prompt_fragment_ref": "artifact", + "tool_hints": ["Bash", "Edit"] } +``` + +### 3.4 `RegistryArtifactsBucket` (S3) + +Artifact bytes (MCP config JSON, Cedar text, skill prompt fragment). Key structure: + +``` +{kind}/{namespace}/{name}/{version}/artifact +``` + +e.g. `mcp_server/acme/pdf-tools/1.4.1/artifact`. Versioning **on**, SSE (S3-managed or KMS), public access blocked, TLS-only bucket policy, lifecycle rule to expire noncurrent versions. Mirrors `ecs-payload-bucket.ts` / `attachments-bucket.ts`. Immutability (ยง5) is enforced at the DynamoDB write, not by S3 object-lock, so `removed` can tombstone a record without a compliance-grade delete (ADR-018 risk bullet). + +## 4. API contract + +All routes are under the existing API Gateway stage (`/v1`), Cognito-authenticated. Wire fields are snake_case (matching the rest of the API). + +### 4.1 `POST /registry/assets` โ€” publish + +Request: + +```jsonc +{ "kind": "mcp_server", "namespace": "acme", "name": "pdf-tools", + "version": "1.4.1", "descriptor": { /* ยง3.3 */ }, + "artifact_b64": "..." } // optional; required for kinds with an artifact +``` + +- Validates kind โˆˆ MVP kinds, semver shape (ยง5), descriptor required fields (ยง3.3). +- **Immutability:** if `(kind, namespace, name, version)` exists โ†’ `409 REGISTRY_VERSION_EXISTS`. +- Uploads artifact to S3 (ยง3.4), writes the DynamoDB row. +- Initial `status`: `submitted` for a `RegistryPublisher`; `approved` if the caller is also a `RegistryApprover` and passes `?auto_approve=true` (dev). See ยง10. +- Response `201`: the created record (minus artifact bytes). + +### 4.2 `GET /registry/resolve?ref=registry://...` โ€” resolve + +- Parses the ref (ยง6), queries candidate versions, ranks by semver, applies the constraint and status rules (ยง5). +- Response `200`: `{ kind, namespace, name, version, descriptor, artifact_url, warnings[] }` where `artifact_url` is a short-lived presigned GET (callers that want the bytes directly). +- Failure: `422 REGISTRY_RESOLUTION_FAILED` with `reason โˆˆ { NO_MATCHING_VERSION, REMOVED, INVALID_CONSTRAINT, INVALID_REGISTRY_REF }`. + +### 4.3 `GET /registry/assets?kind=mcp_server` โ€” list + +- Queries `kind-index`. Optional `?namespace=` filter, `?status=` filter (default: exclude `removed`). +- Response `200`: `{ assets: [{ kind, namespace, name, latest_version, status }] }`. + +### 4.4 `GET /registry/assets/{kind}/{namespace}/{name}` โ€” show + +- `Query` on `pk`; returns every version of one asset with status. +- Response `200`: `{ kind, namespace, name, versions: [{ version, status, created_at, publisher }] }`. + +## 5. Resolution semantics + +**Allowed constraint syntaxes** (validated at publish *and* at blueprint validation): + +| Syntax | Example | Matches | +|--------|---------|---------| +| exact | `1.4.1` | only `1.4.1` | +| caret | `^1.4.1` | `>=1.4.1 <2.0.0` | +| tilde | `~1.4.1` | `>=1.4.1 <1.5.0` | +| *(none)* | `registry://.../pdf-tools` | treated as `*` โ†’ **rejected** | + +**Rejected** at validation time with `INVALID_CONSTRAINT` / `INVALID_REGISTRY_REF`: `*`, `latest`, `>=`, `<=`, `>`, `<`, `x`-ranges, and bare prerelease modifiers. A ref with **no** `@constraint` is rejected โ€” pins are mandatory (fail-closed; no implicit "latest"). + +**Resolution rule:** highest semver-comparable version matching the constraint wins; prereleases rank below their base version (`1.4.1-rc.1` < `1.4.1`). + +**Status handling:** + +| Status | Resolves? | Behavior | +|--------|-----------|----------| +| `approved` | yes | silent | +| `deprecated` | yes | resolves + `warnings: ["DEPRECATED"]` on response and a warning event on the task record | +| `submitted`, `draft`, `rejected` | no | not a candidate; if it's the only match โ†’ `NO_MATCHING_VERSION` | +| `removed` | no | if it's the highest match โ†’ `REMOVED` (distinct reason, so operators see a tombstoned pin vs. a never-existed one) | + +**Fail-closed:** any unresolved ref fails task admission with `REGISTRY_RESOLUTION_FAILED` and a specific reason. A running task never re-resolves or substitutes. + +## 6. URI grammar + +ADR-018 grammar: `registry:////@`. + +The shipped `_REGISTRY_REF` regex (`agent/src/workflow/validator.py`) predates this contract and does **not** match it โ€” it has no `@` and allows only hyphens in the kind segment (so `mcp_server` and `@^1.4.1` both fail). This work **extends** the shipped grammar (ADR-018, corrected per review): the kind segment gains `_` (all MVP kinds are snake_case) and an optional `@` group is added. The extension ships in this PR on **both** sides โ€” Python (`validator.py`) and TypeScript (`registry-resolver.ts`) โ€” and is covered by the parity corpus (ยง12). + +Extended grammar (both languages must agree byte-for-byte): + +``` +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 +``` + +> **Note โ€” two grammars in the tree today.** WORKFLOWS.md examples use a 2-segment `registry://prompt/name` form. The 3-segment ADR-018 grammar above is authoritative for #246; the WORKFLOWS.md examples are illustrative and pre-date this contract. Reconciling those examples is a docs-only follow-up (tracked in the WORKFLOWS.md registry section), not a code change here. + +## 7. Orchestrator integration (preview โ€” full impl in PR 2) + +At the create-task boundary (where `workflow_ref` already resolves, `cdk/src/handlers/shared/`), after loading the Blueprint config and before hydration: + +1. Collect `registry://` refs from the Blueprint's asset fields. +2. `resolveAll(refs)` โ†’ `ResolvedAssetBundle` (ยง8), failing admission on any unresolved ref. +3. Stamp `resolved_assets: [{kind, id, version}]` on the `TaskRecord` (audit). +4. Thread the full bundle into the agent invocation payload. + +PR 1 ships the resolver library and API only; nothing in the orchestrator calls it yet (purely additive). + +## 8. Agent integration (preview โ€” full impl in PR 2/3) + +The agent receives `resolved_assets` in its payload and a per-kind loader applies each: + +- `mcp_server` โ†’ merge `server_config` into `.mcp.json` alongside `channel_mcp.py` output (PR 2). +- `cedar_policy_module` โ†’ append Cedar text to the `PolicyEngine` policy set (PR 3). +- `skill` โ†’ write the prompt fragment into the SDK `setting_sources` extension points (PR 3). + +**`RegistryClient` seam:** both sides talk to a `RegistryClient` abstraction, never to a raw AWS SDK client. The DDB+S3 implementation lives in one file per language; swapping to AgentCore Registry later (or absorbing the 2026-08-06 namespace rename) is confined there. + +## 9. Blueprint construct extension (preview โ€” full impl in PR 2/3) + +`BlueprintProps.assets?: { mcpServers?: string[]; cedarPolicyModules?: string[]; skills?: string[] }`. Each entry is a `registry://` ref, validated at synth (reject floating constraints early). The refs flatten into `RepoConfig` columns (`mcp_servers`, `cedar_policy_modules`, `skills`). Existing inline `security.cedarPolicies` keep working alongside `cedarPolicyModules` refs (mixed case tested in PR 3). + +## 10. Access control (MVP) + +Two Cognito groups (ADR-018 sub-decision 11): + +- **`RegistryPublisher`** โ€” may `POST /registry/assets`; records land in `submitted`. +- **`RegistryApprover`** โ€” may transition `submitted โ†’ approved | rejected` and `approved โ†’ deprecated`, and may `?auto_approve=true` on publish (dev). + +Resolve / list / show are available to any authenticated caller. No per-namespace ACL in MVP. Every status transition appends to `status_history` with actor + timestamp + rationale (audit is a MUST). Cedar-governed publish ACLs are Phase 3 ([#480](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/480)/[#481](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/481)). + +## 11. Relationship to upstream registries + +The registry is a **self-contained catalog** in MVP: assets are published to ABCA's own store, not mirrored from or federated to external registries. This is deliberate โ€” the resolution invariants (semver, immutability, fail-closed, descriptor validation) are ABCA-side guarantees, and an upstream source would have to satisfy them before ABCA could depend on it. + +Ecosystem catalogs exist and are relevant as **future discovery sources**, not MVP substrates: + +- The official **MCP registry** (`registry.modelcontextprotocol.io`, [modelcontextprotocol/registry](https://github.com/modelcontextprotocol/registry)) โ€” the closest analogue for the `mcp_server` kind. +- **AWS Agent Registry (Bedrock AgentCore)** โ€” the ADR-018 *preferred* substrate; the `RegistryClient` seam (ยง8) is the swap point. +- Language/package precedents (PyPI, npm, container registries) โ€” the "registry of registries" model. + +**MVP answer to "how is the lookup maintained":** by publishers, via the publish API, into ABCA's own DynamoDB catalog โ€” ABCA is the single source of truth for what a task can load, so the resolution contract is enforceable. **Federation** (ingesting/mirroring an upstream registry behind the same `RegistryClient` interface, with a trust/verification gate) is a Phase 3 option the seam permits without re-opening the contract. It is out of scope for #246 and should not be added by scope-creep, because an unverified upstream would undermine the fail-closed guarantee. + +## 12. Test plan + +- **Resolver unit tests** (`cdk/test/handlers/shared/registry-resolver.test.ts`): URI parse valid/invalid; semver match for exact/`^`/`~`; highest-version selection; prerelease ranking; no-match โ†’ `NO_MATCHING_VERSION`; `deprecated` โ†’ warning; `removed` โ†’ `REMOVED`; floating constraint โ†’ `INVALID_CONSTRAINT`. +- **Grammar parity corpus** (`contracts/registry-resolution/`): annotated `(ref) โ†’ verdict` fixtures run against **both** the Python `_REGISTRY_REF` and the TS `parseRef`, mirroring `contracts/cedar-parity/` and `contracts/workflow-validation/`. Includes the exact cases from the PR #548 review (snake_case kinds, `@constraint`). +- **Handler tests**: publish happy path, `409` immutability, descriptor validation errors, auth refusal (non-publisher), resolve/list/show. +- **Construct tests**: `registry-assets-table.test.ts`, `registry-artifacts-bucket.test.ts` (encryption, versioning, public-access-block, TLS policy). +- **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. Out of scope (explicit) + +Transitive registry-asset dependencies; plugin/subagent/prompt_fragment/capability loaders; upstream federation; per-namespace ACL; EventBridge as primary event bus; migrating first-party workflows into the registry; compliance-grade (GDPR) deletion of artifact bytes (`removed` tombstones the record only). diff --git a/docs/src/content/docs/architecture/Deployment-roles.md b/docs/src/content/docs/architecture/Deployment-roles.md index a852a9b67..6dc8b2738 100644 --- a/docs/src/content/docs/architecture/Deployment-roles.md +++ b/docs/src/content/docs/architecture/Deployment-roles.md @@ -395,6 +395,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", diff --git a/docs/src/content/docs/architecture/Registry.md b/docs/src/content/docs/architecture/Registry.md new file mode 100644 index 000000000..577a63fba --- /dev/null +++ b/docs/src/content/docs/architecture/Registry.md @@ -0,0 +1,248 @@ +--- +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 storage schema (DynamoDB + S3), the publish/resolve/list/show API contract, resolution semantics (semver, immutability, status), 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 this generalizes, [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. +- **Decision record:** [ADR-018](/sample-autonomous-cloud-coding-agents/architecture/adr-018-agent-asset-registry). +- **Tracking issue:** [#246](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/246). Child issues: [#478](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/478) (lifecycle), [#479](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/479) (versioning + immutability), [#480](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/480) (Blueprint integration + ACLs), [#481](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/481) (capability descriptors). + +> **Substrate.** ADR-018 prefers AWS Agent Registry (Bedrock AgentCore) but requires a design-time prototype and defines four fall-back conditions. This document specifies the **DynamoDB + S3 fallback** (ADR-018 substrate option 2), which the fall-back conditions already select at authoring time (AgentCore Registry is in public preview and hard-migrates namespaces on 2026-08-06). The `RegistryClient` seam (ยง8) keeps the AgentCore path open as a later swap without changing the contract. + +## 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 / Phase 3):** + +- Transitive dependencies between registry assets (explicitly disallowed in MVP). +- Plugin, subagent, prompt_fragment, and capability (= workflow) loaders โ€” declared in the schema, not loaded. +- Federation to / mirroring from upstream registries (official MCP registry, CNCF catalogs) โ€” see ยง11. +- Cedar-governed publish ACLs and per-namespace granularity โ€” MVP uses two Cognito groups (ยง10). +- EventBridge as a primary bus for asset events; migrating first-party workflows into the registry. + +## 2. Asset kinds for MVP + +| Kind | MVP status | Inline content | Applied by | +|------|-----------|----------------|-----------| +| `mcp_server` | **implemented E2E** | `server_config` (an `mcpServers` entry) | agent โ†’ merged into `.mcp.json` | +| `cedar_policy_module` | **implemented** | `cedar_text` (Cedar policy source) | orchestrator โ†’ merged into the `cedar_policies` payload (byte-identical to inline blueprint policies) โ†’ agent `PolicyEngine` | +| `skill` | **implemented** | `prompt_fragment` (+ `tool_hints`) | agent โ†’ appended to the system prompt | +| `plugin`, `subagent`, `prompt_fragment`, `capability` | **declared, not loaded** | โ€” | โ€” (reserved kinds; ยง12 of ADR-018) | + +`capability` is reserved for workflows (ADR-014 vocabulary); workflows do **not** migrate into the registry in this work (ADR-018 sub-decision 12). Reserved kinds are accepted by the grammar but publish rejects them until a loader ships. + +**Inline content + the `content` seam.** All three MVP kinds carry their loadable body **inline in the descriptor** (`server_config` / `cedar_text` / `prompt_fragment`), capped at 64 KiB at publish. The resolver exposes it on `ResolvedAsset.content` (for the text kinds) so callers never depend on *where* the bytes live โ€” swapping to an S3/AgentCore-backed fetch later (for larger assets or the preferred substrate) is confined to the resolver and invisible to the orchestrator and agent. + +**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 โ€” the cedar-parity contract holds by construction. **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. Schema + +### 3.1 `RegistryAssetsTable` (DynamoDB) + +Metadata records. One row per published `(kind, namespace, name, version)`. + +| Attribute | Type | Key | Notes | +|-----------|------|-----|-------| +| `pk` | S | **partition** | `{kind}#{namespace}/{name}` โ€” e.g. `mcp_server#acme/pdf-tools` | +| `sk` | S | **sort** | `version` โ€” e.g. `1.4.1` (semver string) | +| `kind` | S | | denormalized for the list GSI | +| `namespace` | S | | | +| `name` | S | | | +| `version` | S | | semver, immutable once written | +| `descriptor` | M | | typed per-kind descriptor (ยง3.3) โ€” validated at publish | +| `artifact_ref` | S | | S3 key (ยง3.4); empty for descriptor-only kinds | +| `status` | S | | `draft` \| `submitted` \| `approved` \| `rejected` \| `deprecated` \| `removed` (ยง5) | +| `publisher` | S | | Cognito `sub` of the publishing principal | +| `created_at` | S | | ISO-8601, set at publish | +| `status_history` | L | | append-only audit: `[{status, actor, at, rationale}]` | + +**GSI `kind-index`** โ€” partition `kind`, sort `pk` โ€” powers `GET /registry/assets?kind=mcp_server` (list) without a scan. + +> **Canonical status token.** The approved-and-resolvable state is spelled **`approved`** everywhere โ€” in DynamoDB, the TypeScript resolver, and the Python loader. ADR-018 prose mentions "`active`" as a synonym; that word is **not** a value in code. One token, byte-for-byte, across both languages (the parity hazard ADR-018's `(โˆ’)` bullet flags). + +### 3.2 Partition/sort rationale + +`pk = {kind}#{namespace}/{name}` groups every version of one asset under a single partition; `sk = version` orders them. `GET /registry/assets/{id}` (show all versions) is a single `Query` on `pk`. Resolution (`resolveRef`) is a `Query` on `pk` that returns all versions, then ranks client-side by parsed semver (ยง5) โ€” DynamoDB cannot sort semver lexicographically (`1.10.0` < `1.9.0` as strings), so ranking is always in code. + +### 3.3 Per-kind descriptor shapes + +Every record carries a typed `descriptor`, validated at publish (ยง4). Shared required fields: `summary` (string), `permissions` (list of strings). Per kind: + +```jsonc +// mcp_server +{ "summary": "...", "permissions": ["network:egress"], + "transport": "http" | "stdio", + "egress_domains": ["mcp.example.com"], // feeds Blueprint egress review + "tool_prefix": "mcp__example__", // tools surface under this prefix + "server_config": { /* mcpServers entry, or in artifact for large configs */ } } + +// cedar_policy_module +{ "summary": "...", "permissions": [], + "cedar_actions": ["Action::\"ForcePush\""], // actions the module introduces + "policy_text_ref": "artifact" } // Cedar text lives in the artifact + +// skill +{ "summary": "...", "permissions": [], + "prompt_fragment_ref": "artifact", + "tool_hints": ["Bash", "Edit"] } +``` + +### 3.4 `RegistryArtifactsBucket` (S3) + +Artifact bytes (MCP config JSON, Cedar text, skill prompt fragment). Key structure: + +``` +{kind}/{namespace}/{name}/{version}/artifact +``` + +e.g. `mcp_server/acme/pdf-tools/1.4.1/artifact`. Versioning **on**, SSE (S3-managed or KMS), public access blocked, TLS-only bucket policy, lifecycle rule to expire noncurrent versions. Mirrors `ecs-payload-bucket.ts` / `attachments-bucket.ts`. Immutability (ยง5) is enforced at the DynamoDB write, not by S3 object-lock, so `removed` can tombstone a record without a compliance-grade delete (ADR-018 risk bullet). + +## 4. API contract + +All routes are under the existing API Gateway stage (`/v1`), Cognito-authenticated. Wire fields are snake_case (matching the rest of the API). + +### 4.1 `POST /registry/assets` โ€” publish + +Request: + +```jsonc +{ "kind": "mcp_server", "namespace": "acme", "name": "pdf-tools", + "version": "1.4.1", "descriptor": { /* ยง3.3 */ }, + "artifact_b64": "..." } // optional; required for kinds with an artifact +``` + +- Validates kind โˆˆ MVP kinds, semver shape (ยง5), descriptor required fields (ยง3.3). +- **Immutability:** if `(kind, namespace, name, version)` exists โ†’ `409 REGISTRY_VERSION_EXISTS`. +- Uploads artifact to S3 (ยง3.4), writes the DynamoDB row. +- Initial `status`: `submitted` for a `RegistryPublisher`; `approved` if the caller is also a `RegistryApprover` and passes `?auto_approve=true` (dev). See ยง10. +- Response `201`: the created record (minus artifact bytes). + +### 4.2 `GET /registry/resolve?ref=registry://...` โ€” resolve + +- Parses the ref (ยง6), queries candidate versions, ranks by semver, applies the constraint and status rules (ยง5). +- Response `200`: `{ kind, namespace, name, version, descriptor, artifact_url, warnings[] }` where `artifact_url` is a short-lived presigned GET (callers that want the bytes directly). +- Failure: `422 REGISTRY_RESOLUTION_FAILED` with `reason โˆˆ { NO_MATCHING_VERSION, REMOVED, INVALID_CONSTRAINT, INVALID_REGISTRY_REF }`. + +### 4.3 `GET /registry/assets?kind=mcp_server` โ€” list + +- Queries `kind-index`. Optional `?namespace=` filter, `?status=` filter (default: exclude `removed`). +- Response `200`: `{ assets: [{ kind, namespace, name, latest_version, status }] }`. + +### 4.4 `GET /registry/assets/{kind}/{namespace}/{name}` โ€” show + +- `Query` on `pk`; returns every version of one asset with status. +- Response `200`: `{ kind, namespace, name, versions: [{ version, status, created_at, publisher }] }`. + +## 5. Resolution semantics + +**Allowed constraint syntaxes** (validated at publish *and* at blueprint validation): + +| Syntax | Example | Matches | +|--------|---------|---------| +| exact | `1.4.1` | only `1.4.1` | +| caret | `^1.4.1` | `>=1.4.1 <2.0.0` | +| tilde | `~1.4.1` | `>=1.4.1 <1.5.0` | +| *(none)* | `registry://.../pdf-tools` | treated as `*` โ†’ **rejected** | + +**Rejected** at validation time with `INVALID_CONSTRAINT` / `INVALID_REGISTRY_REF`: `*`, `latest`, `>=`, `<=`, `>`, `<`, `x`-ranges, and bare prerelease modifiers. A ref with **no** `@constraint` is rejected โ€” pins are mandatory (fail-closed; no implicit "latest"). + +**Resolution rule:** highest semver-comparable version matching the constraint wins; prereleases rank below their base version (`1.4.1-rc.1` < `1.4.1`). + +**Status handling:** + +| Status | Resolves? | Behavior | +|--------|-----------|----------| +| `approved` | yes | silent | +| `deprecated` | yes | resolves + `warnings: ["DEPRECATED"]` on response and a warning event on the task record | +| `submitted`, `draft`, `rejected` | no | not a candidate; if it's the only match โ†’ `NO_MATCHING_VERSION` | +| `removed` | no | if it's the highest match โ†’ `REMOVED` (distinct reason, so operators see a tombstoned pin vs. a never-existed one) | + +**Fail-closed:** any unresolved ref fails task admission with `REGISTRY_RESOLUTION_FAILED` and a specific reason. A running task never re-resolves or substitutes. + +## 6. URI grammar + +ADR-018 grammar: `registry:////@`. + +The shipped `_REGISTRY_REF` regex (`agent/src/workflow/validator.py`) predates this contract and does **not** match it โ€” it has no `@` and allows only hyphens in the kind segment (so `mcp_server` and `@^1.4.1` both fail). This work **extends** the shipped grammar (ADR-018, corrected per review): the kind segment gains `_` (all MVP kinds are snake_case) and an optional `@` group is added. The extension ships in this PR on **both** sides โ€” Python (`validator.py`) and TypeScript (`registry-resolver.ts`) โ€” and is covered by the parity corpus (ยง12). + +Extended grammar (both languages must agree byte-for-byte): + +``` +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 +``` + +> **Note โ€” two grammars in the tree today.** WORKFLOWS.md examples use a 2-segment `registry://prompt/name` form. The 3-segment ADR-018 grammar above is authoritative for #246; the WORKFLOWS.md examples are illustrative and pre-date this contract. Reconciling those examples is a docs-only follow-up (tracked in the WORKFLOWS.md registry section), not a code change here. + +## 7. Orchestrator integration (preview โ€” full impl in PR 2) + +At the create-task boundary (where `workflow_ref` already resolves, `cdk/src/handlers/shared/`), after loading the Blueprint config and before hydration: + +1. Collect `registry://` refs from the Blueprint's asset fields. +2. `resolveAll(refs)` โ†’ `ResolvedAssetBundle` (ยง8), failing admission on any unresolved ref. +3. Stamp `resolved_assets: [{kind, id, version}]` on the `TaskRecord` (audit). +4. Thread the full bundle into the agent invocation payload. + +PR 1 ships the resolver library and API only; nothing in the orchestrator calls it yet (purely additive). + +## 8. Agent integration (preview โ€” full impl in PR 2/3) + +The agent receives `resolved_assets` in its payload and a per-kind loader applies each: + +- `mcp_server` โ†’ merge `server_config` into `.mcp.json` alongside `channel_mcp.py` output (PR 2). +- `cedar_policy_module` โ†’ append Cedar text to the `PolicyEngine` policy set (PR 3). +- `skill` โ†’ write the prompt fragment into the SDK `setting_sources` extension points (PR 3). + +**`RegistryClient` seam:** both sides talk to a `RegistryClient` abstraction, never to a raw AWS SDK client. The DDB+S3 implementation lives in one file per language; swapping to AgentCore Registry later (or absorbing the 2026-08-06 namespace rename) is confined there. + +## 9. Blueprint construct extension (preview โ€” full impl in PR 2/3) + +`BlueprintProps.assets?: { mcpServers?: string[]; cedarPolicyModules?: string[]; skills?: string[] }`. Each entry is a `registry://` ref, validated at synth (reject floating constraints early). The refs flatten into `RepoConfig` columns (`mcp_servers`, `cedar_policy_modules`, `skills`). Existing inline `security.cedarPolicies` keep working alongside `cedarPolicyModules` refs (mixed case tested in PR 3). + +## 10. Access control (MVP) + +Two Cognito groups (ADR-018 sub-decision 11): + +- **`RegistryPublisher`** โ€” may `POST /registry/assets`; records land in `submitted`. +- **`RegistryApprover`** โ€” may transition `submitted โ†’ approved | rejected` and `approved โ†’ deprecated`, and may `?auto_approve=true` on publish (dev). + +Resolve / list / show are available to any authenticated caller. No per-namespace ACL in MVP. Every status transition appends to `status_history` with actor + timestamp + rationale (audit is a MUST). Cedar-governed publish ACLs are Phase 3 ([#480](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/480)/[#481](https://github.com/aws-samples/sample-autonomous-cloud-coding-agents/issues/481)). + +## 11. Relationship to upstream registries + +The registry is a **self-contained catalog** in MVP: assets are published to ABCA's own store, not mirrored from or federated to external registries. This is deliberate โ€” the resolution invariants (semver, immutability, fail-closed, descriptor validation) are ABCA-side guarantees, and an upstream source would have to satisfy them before ABCA could depend on it. + +Ecosystem catalogs exist and are relevant as **future discovery sources**, not MVP substrates: + +- The official **MCP registry** (`registry.modelcontextprotocol.io`, [modelcontextprotocol/registry](https://github.com/modelcontextprotocol/registry)) โ€” the closest analogue for the `mcp_server` kind. +- **AWS Agent Registry (Bedrock AgentCore)** โ€” the ADR-018 *preferred* substrate; the `RegistryClient` seam (ยง8) is the swap point. +- Language/package precedents (PyPI, npm, container registries) โ€” the "registry of registries" model. + +**MVP answer to "how is the lookup maintained":** by publishers, via the publish API, into ABCA's own DynamoDB catalog โ€” ABCA is the single source of truth for what a task can load, so the resolution contract is enforceable. **Federation** (ingesting/mirroring an upstream registry behind the same `RegistryClient` interface, with a trust/verification gate) is a Phase 3 option the seam permits without re-opening the contract. It is out of scope for #246 and should not be added by scope-creep, because an unverified upstream would undermine the fail-closed guarantee. + +## 12. Test plan + +- **Resolver unit tests** (`cdk/test/handlers/shared/registry-resolver.test.ts`): URI parse valid/invalid; semver match for exact/`^`/`~`; highest-version selection; prerelease ranking; no-match โ†’ `NO_MATCHING_VERSION`; `deprecated` โ†’ warning; `removed` โ†’ `REMOVED`; floating constraint โ†’ `INVALID_CONSTRAINT`. +- **Grammar parity corpus** (`contracts/registry-resolution/`): annotated `(ref) โ†’ verdict` fixtures run against **both** the Python `_REGISTRY_REF` and the TS `parseRef`, mirroring `contracts/cedar-parity/` and `contracts/workflow-validation/`. Includes the exact cases from the PR #548 review (snake_case kinds, `@constraint`). +- **Handler tests**: publish happy path, `409` immutability, descriptor validation errors, auth refusal (non-publisher), resolve/list/show. +- **Construct tests**: `registry-assets-table.test.ts`, `registry-artifacts-bucket.test.ts` (encryption, versioning, public-access-block, TLS policy). +- **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. Out of scope (explicit) + +Transitive registry-asset dependencies; plugin/subagent/prompt_fragment/capability loaders; upstream federation; per-namespace ACL; EventBridge as primary event bus; migrating first-party workflows into the registry; compliance-grade (GDPR) deletion of artifact bytes (`removed` tombstones the record only). diff --git a/scripts/check-types-sync.ts b/scripts/check-types-sync.ts index 68ce017a1..b71798cf0 100644 --- a/scripts/check-types-sync.ts +++ b/scripts/check-types-sync.ts @@ -104,6 +104,11 @@ const CDK_ONLY_ALLOWLIST = new Set([ // Internal extension shape used by create-task-core.ts to thread // Cedar HITL fields without widening the public CreateTaskRequest: 'CreateTaskApprovalExtensions', + // Registry (#246) server-only persistence/audit shapes โ€” the CLI consumes + // the resolved/wire types (RegistryRef, ResolvedAsset, etc.) but never the + // raw DDB row or its audit-event entries. See docs/design/REGISTRY.md ยง3.1. + 'RegistryAssetRecord', + 'RegistryStatusEvent', // Server-side bound constants โ€” sourced from contracts/constants.json // (S9). Cross-language drift is enforced by scripts/check-constants-sync.ts. 'APPROVAL_GATE_CAP_MIN', @@ -143,6 +148,14 @@ const CLI_ONLY_ALLOWLIST = new Set([ 'LinearLinkResponse', 'JiraLinkResponse', 'TraceUrlResponse', + // Registry (#246) command request/response envelopes โ€” the server returns + // inline objects (not named types), so these CLI-facing shapes are CLI-only. + 'RegistryPublishRequest', + 'RegistryPublishResponse', + 'RegistryAssetListItem', + 'RegistryListResponse', + 'RegistryVersionItem', + 'RegistryShowResponse', // Error classification โ€” derived server-side via a function and // emitted on TaskDetail. The CLI consumes the resulting interface // but the union of category strings is a CLI-only display diff --git a/yarn.lock b/yarn.lock index a319441e3..8f74da0d0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3020,6 +3020,11 @@ dependencies: "@types/node" "*" +"@types/semver@^7.7.1": + version "7.7.1" + resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.7.1.tgz#3ce3af1a5524ef327d2da9e4fd8b6d95c8d70528" + integrity sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA== + "@types/stack-utils@^2.0.3": version "2.0.3" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8"