Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,21 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# `version` is pinned deliberately. ruff-action derives it from the nearest
# pyproject.toml, and this repo has none at the root (the Python projects are
# backend/ and infra/), so it silently resolved to `latest` -- which made this
# job's verdict depend on Astral's release date rather than on the commit.
# 0.16 started formatting Python blocks *inside* markdown, so PRs began
# failing on doc files their authors never touched, and `ruff format --check`
# passing locally no longer predicted CI. Keep this equal to the `ruff` pin in
# backend/pyproject.toml's dev extras; Dependabot bumps them as a reviewable PR.
- uses: astral-sh/ruff-action@278981a28ce3188b1e39527901f38254bf3aac89 # v4.1.0
with:
version: 0.16.4
args: check .
- uses: astral-sh/ruff-action@278981a28ce3188b1e39527901f38254bf3aac89 # v4.1.0
with:
version: 0.16.4
args: format --check .
# Non-blocking while the backlog of type errors is burned down;
# flip continue-on-error to false once `npx pyright` is clean.
Expand Down
99 changes: 99 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,105 @@ All notable changes to this project are documented here. The format follows

## [Unreleased]

### Changed — AWS Agent Registry: preview → GA
Agent Registry graduated out of AgentCore into its own AWS service. The rename is
a **silent** break: the deprecated `bedrock-agentcore-control` model still exposes
the Registry operations with the old `descriptorType` parameter, so preview code
keeps "succeeding" against a shim under an IAM prefix it no longer has. Migrated
end-to-end:
- boto3 clients `bedrock-agentcore-control`/`bedrock-agentcore` →
`agent-registry-control`/`agent-registry`; IAM actions `bedrock-agentcore:*` →
`agent-registry:*` (both planes sign as `agent-registry`)
- `descriptorType` → `recordType`, with the enum renamed
`MCP|A2A|CUSTOM|AGENT_SKILLS` → `MCP|AGENT|CUSTOM|SKILL` (preview spellings are
still accepted as input aliases)
- Descriptors reshaped: `a2a.agentCard.inlineContent` → `a2aAgentCard.data`,
`custom.inlineContent` → `custom.data`, `schemaVersion` → `dataSchemaVersion`;
added `mcpServer` and `agentSkillsDefinition` builders
- Data-plane `SearchRegistryRecords` → `SearchDiscoverableRegistryRecords`, with
the GA structured filter shape (`{"recordType": {"$in": [...]}}`)
- `boto3 >= 1.43.66` is now a hard floor (first release carrying the
`agent-registry` service models) in both `pyproject.toml` and
`requirements-lambda.txt`
- `GET /api/registry/aws-config` gained `sdk_supported`, and `POST` now returns a
400 naming the SDK instead of blaming the `registry_id`, so an under-pinned
bundle is distinguishable from a bad registryId

All of the below was verified against the live GA service, not just the boto3
models: a throwaway registry, every descriptor builder submitted through the
shipping adapter, and the approval lifecycle exercised end to end.

### Fixed — found by live verification against GA
- **Every redeploy silently failed to re-register.** `name` + `recordVersion` is a
uniqueness key and `recordVersion` is `"1.0"` for everything the platform
registers, so the *second* deployment of an agent raised `ConflictException`
inside the best-effort auto-register handler. The symptom was a governance record
frozen at the first deployment's runtime ARN and endpoint — stale forever, with
nothing surfaced anywhere. `register()` is now an upsert (falling back to
`UpdateRegistryRecord`), which needs the new
`agent-registry:UpdateRegistryRecord` grant on the `status_update` step role.
Note updating content demotes a record `APPROVED` → `DRAFT`, so an upsert cannot
slip changed content past an old approval — a redeployed integration must be
re-approved, which is the fail-closed reading.
- **`available()` reported a still-provisioning registry as usable.** It returned
True the instant `GetRegistry` succeeded, but a registry in
`CREATING`/`UPDATING`/`DELETING` rejects `CreateRegistryRecord` with
`ConflictException`. Enabling federation on a freshly created registry — the
common sequence — therefore passed validation and then raced into that conflict
on the first deploy. Now gated on `READY`, with a new `registry_status()` that
keeps "not READY" distinct from "could not ask"; `POST /aws-config` returns 409
("still provisioning") instead of a 400 blaming the registryId.
- **Search results could show a stale `APPROVED` badge.** The data plane is a
search index, not the record store: a record demoted to `DRAFT` keeps being
served as `APPROVED` for many minutes (still drifting 20 minutes after
demotion). Combined with the upsert this is reachable on the ordinary redeploy
path. `GET /api/registry/aws-search` now reconciles every hit's status against
the control plane and reports `status_authoritative: false` — dropping `status`
rather than serving the index's copy — when it cannot. Approval *gating* always
read the control plane and was never affected; a new AST-level guard test keeps
it that way.
- **Descriptor content contracts corrected** (each one an outright rejection by the
live schema validator, reported only as an unactionable descriptor-wide error):
A2A card skills require *all* of `id`/`name`/`description`/`tags` (empty `tags`
is fine, absent is not) and the card requires `url`; `mcpServer.data` is an MCP
server.json whose `name` must be namespaced `<namespace>/<server>` (a bare name
is rejected) with `description` and `version` required; `agentSkillsDefinition`
must omit `dataSchemaVersion` entirely, unlike every other descriptor; and both
the tools and skills payloads must be objects (`{"tools": [...]}`), never bare
arrays. Under-specified inputs are now normalized rather than forwarded.
- `UpdateRegistryRecord` takes a different shape from `CreateRegistryRecord` —
every branch and scalar leaf is wrapped in an `optionalValue` patch envelope.
Passing the create shape fails in botocore's *client-side* validation, never
reaching AWS, and on the deploy path that lands in the best-effort handler.

### Fixed
- **Auto-register on deploy never worked**: the `status_update` step Lambda — the
role that actually calls `CreateRegistryRecord` — had no registry permissions at
all, so every federation attempt was an `AccessDenied` swallowed by the
best-effort handler. The exception cause is now logged rather than discarded.
- **An unqueryable registry was indistinguishable from a rejected integration.**
Gating swallowed every error into "nothing is approved", so an `AccessDenied` on
`agent-registry:ListRegistryRecords` — or a registryId typo — rendered as a 403
telling the operator their integrations had been *rejected*, sending them to fix
a governance record when the fault was an IAM policy. Absent data and negative
data are now distinct: `list_records_strict()` raises `RegistryQueryFailed`,
which surfaces as a 503 naming the registry as unreachable. Gating stays
fail-closed for a *successful* query that finds no approval.
- **`list_records()` returned only the first page**, so fail-closed integration
gating could block a deploy against an integration that *is* `APPROVED` further
down the list. Now follows `nextToken`, and pushes the `APPROVED` narrowing
server-side via the GA `filters` parameter.
- Registry adapter degrades instead of raising when the bundled boto3 predates GA
(`boto3.client()` raising `UnknownServiceError` used to 500
`GET /api/registry/aws-config`).
- Descriptor `data` payloads are now checked against the service's 102400-**byte**
cap (measured in bytes, not characters) with an error naming which descriptor
overflowed. AWS's `ValidationException` identifies neither, and on the deploy
path it lands in a best-effort handler that would reduce it to a log line.
- `frontend/src/services/api.ts` carried a second, independent declaration of
`getAwsRegistryConfig()`'s return type; only `tsc -b` (project references, as CI
runs it) surfaced the mismatch — `tsc -p` on the root project did not.

### Added
- GitHub Actions CI: ruff lint/format, backend unit tests with coverage floor,
CDK assertion tests + `cdk synth` (cdk-nag gate), frontend lint/typecheck/tests/build
Expand Down
10 changes: 9 additions & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ requires-python = ">=3.11"
dependencies = [
"fastapi>=0.115.0",
"pydantic>=2.10.0",
"boto3>=1.35.0",
# 1.43.66 is the first release with the GA `agent-registry-control` /
# `agent-registry` service models (Agent Registry left the
# `bedrock-agentcore` namespace at GA).
"boto3>=1.43.66",
"uvicorn>=0.32.0",
"python-dotenv>=1.0.0",
"mangum>=0.19.0",
Expand All @@ -21,6 +24,11 @@ dev = [
"httpx>=0.28.0",
"moto[dynamodb]>=5.0.0",
"requests>=2.31.0",
# Exact pin, unlike everything else here: a formatter's output changes between
# minor releases, so `>=` would mean `ruff format --check` locally and in CI
# could disagree while both were "satisfied". Must match the `version:` pinned
# on the ruff-action steps in .github/workflows/ci.yml.
"ruff==0.16.4",
]
deploy = [
"bedrock-agentcore-starter-toolkit",
Expand Down
8 changes: 7 additions & 1 deletion backend/requirements-lambda.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
fastapi>=0.115.0
pydantic>=2.10.0
boto3>=1.35.0
# boto3 >= 1.43.66 is REQUIRED, not merely preferred: it is the first release
# carrying the `agent-registry-control` / `agent-registry` service models that
# Agent Registry moved to at GA. On an older bundle boto3.client(...) raises
# UnknownServiceError and registry federation degrades to "unreachable".
# This must stay ahead of the Lambda runtime's built-in boto3, which PYTHONPATH
# (/var/task/lib) shadows.
boto3>=1.43.66
mangum>=0.19.0
python-dotenv>=1.0.0
pyyaml>=6.0.3
Expand Down
25 changes: 23 additions & 2 deletions backend/src/app/deployment_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -466,7 +466,10 @@ async def handle_deploy(request: DeployRequest, raw_request: Request) -> DeployR
# peers. Collect the connected external identifiers (endpoint URLs / names)
# and reject the deploy if any is not APPROVED. No-op when federation is off.
try:
from app.services.aws_agent_registry import unapproved_integrations
from app.services.aws_agent_registry import (
RegistryQueryFailed,
unapproved_integrations,
)

_idents: list[str] = []
_mcp = request.mcp_server_config or {}
Expand All @@ -478,7 +481,25 @@ async def handle_deploy(request: DeployRequest, raw_request: Request) -> DeployR
if isinstance(_a2a, dict):
for u in _a2a.get("peer_allowlist") or _a2a.get("peerAllowlist") or []:
_idents.append(str(u))
_blocked = unapproved_integrations(_idents)
try:
_blocked = unapproved_integrations(_idents)
except RegistryQueryFailed as _rqe:
# Still fail CLOSED — a governance control that opens on error is not a
# control. But 503, not 403, and name the real cause: approval status is
# UNKNOWN, not "denied". Reporting this as 403 would send the operator to
# approve records that may already be approved, while the actual fix is an
# IAM action or the registry id.
logger.error("integration gating could not resolve approval status: %s", _rqe)
raise HTTPException(
status_code=503,
detail=(
"Integration gating is enabled but the Agent Registry could not be "
f"queried, so approval status is unknown ({_rqe}). Refusing the deploy "
"rather than let an unreviewed integration through. Check that the "
"deployment role holds agent-registry:ListRegistryRecords and that the "
"configured registry id is correct."
),
) from _rqe
if _blocked:
raise HTTPException(
status_code=403,
Expand Down
112 changes: 102 additions & 10 deletions backend/src/app/routers/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -331,14 +331,45 @@ class AwsRegistryEnableRequest(BaseModel):

@router.get("/aws-config", dependencies=[Depends(require_scopes("registry:read"))])
async def aws_registry_config(caller_sub: str = Depends(get_caller_sub)) -> dict:
"""Return whether AWS Agent Registry federation is enabled + reachable."""
from app.services.aws_agent_registry import get_configured_registry_id, get_registry
"""Return whether AWS Agent Registry federation is enabled + reachable.

``sdk_supported`` distinguishes the two very different reasons federation can
be unreachable: a bad registryId / missing IAM (fixable in the console) vs a
Lambda bundle whose boto3 predates the GA ``agent-registry`` service models
(fixable only by redeploying with boto3 >= 1.43.66). Without it the UI can
only say "unreachable", which sends admins hunting the wrong problem.
``status`` covers the third case: a valid, permitted registry that is not yet
READY. It is None when the registry could not be read at all.
"""
from app.services.aws_agent_registry import (
REGISTRY_STATUS_READY,
agent_registry_supported,
get_configured_registry_id,
get_registry,
)

sdk_ok = agent_registry_supported()
rid = get_configured_registry_id()
if not rid:
return {"enabled": False, "registry_id": None, "available": False}
return {
"enabled": False,
"registry_id": None,
"available": False,
"sdk_supported": sdk_ok,
"status": None,
}
reg = get_registry()
return {"enabled": True, "registry_id": rid, "available": bool(reg and reg.available())}
# `status` splits the third reason federation can look broken: the registry is
# real and permitted but not READY (CREATING/UPDATING/DELETING). available()
# alone renders that identically to a bad registryId.
status = reg.registry_status() if reg else None
return {
"enabled": True,
"registry_id": rid,
"available": status == REGISTRY_STATUS_READY,
"sdk_supported": sdk_ok,
"status": status,
}


@router.post("/aws-config", dependencies=[Depends(require_scopes("registry:write"))])
Expand All @@ -350,14 +381,43 @@ async def aws_registry_enable(
"""Enable AWS Agent Registry federation with a registryId. Admin only."""
if not is_admin:
raise HTTPException(status_code=403, detail="Requires registry-admin role")
from app.services.aws_agent_registry import AwsAgentRegistry, set_configured_registry_id
from app.services.aws_agent_registry import (
MIN_BOTO3,
REGISTRY_STATUS_READY,
AwsAgentRegistry,
agent_registry_supported,
set_configured_registry_id,
)

# Validate reachability before persisting so a typo fails loudly here.
if not AwsAgentRegistry(body.registry_id).available():
# An old bundle has no agent-registry client at all, so available() would be
# False for a perfectly valid registryId. Say so, rather than blaming the id.
if not agent_registry_supported():
raise HTTPException(
status_code=400,
detail=(
"This deployment's AWS SDK predates the GA Agent Registry API. "
f"Redeploy with boto3 >= {'.'.join(str(p) for p in MIN_BOTO3)}."
),
)
# Validate reachability before persisting so a typo fails loudly here. Read the
# status rather than just available(), so "still being created" is not reported
# as "wrong registryId" — a registry takes tens of seconds to reach READY, and
# enabling federation right after creating one is the normal sequence.
status = AwsAgentRegistry(body.registry_id).registry_status()
if status is None:
raise HTTPException(
status_code=400,
detail="Registry not reachable (check the registryId / region / permissions)",
)
if status != REGISTRY_STATUS_READY:
raise HTTPException(
status_code=409,
detail=(
f"Registry {body.registry_id} exists but its status is {status}, not "
f"{REGISTRY_STATUS_READY}; it cannot accept records yet. "
"Retry once it finishes provisioning."
),
)
set_configured_registry_id(body.registry_id)
return {"enabled": True, "registry_id": body.registry_id, "available": True}

Expand All @@ -367,13 +427,45 @@ async def aws_registry_search(
q: str = Query(min_length=1, max_length=256),
caller_sub: str = Depends(get_caller_sub),
) -> dict:
"""Semantic search across the AWS Agent Registry (empty when disabled)."""
from app.services.aws_agent_registry import get_registry
"""Semantic search across the AWS Agent Registry (empty when disabled).

Results come from the data plane's search index, whose per-record ``status``
lags the control plane — a record demoted APPROVED -> DRAFT (which is what a
redeploy does, since register() upserts) keeps being served as APPROVED. This
is a governance surface, so showing that stale badge would tell a reviewer an
integration is approved when it is waiting on re-review.

So each hit's status is overwritten from the authoritative control-plane
listing. Best-effort by design: if that listing fails we drop ``status``
rather than fail the request or pass the index's version through, because
"unknown" is honest and the other two options are respectively useless and
misleading. ``status_authoritative`` tells the UI which case it got.
"""
from app.services.aws_agent_registry import RegistryQueryFailed, get_registry

reg = get_registry()
if reg is None:
return {"enabled": False, "results": []}
return {"enabled": True, "results": reg.search(q)}

results = reg.search(q)
authoritative = True
try:
truth = {r.get("recordId"): r.get("status") for r in reg.list_records_strict() if r.get("recordId")}
except RegistryQueryFailed as e:
logger.info("could not reconcile search statuses against the control plane: %s", e)
truth, authoritative = {}, False

for hit in results:
if not isinstance(hit, dict):
continue
if authoritative:
# A hit absent from the control plane was deleted but not yet
# de-indexed; report it as gone rather than as its last known status.
hit["status"] = truth.get(hit.get("recordId"), "DELETED")
else:
hit.pop("status", None)

return {"enabled": True, "results": results, "status_authoritative": authoritative}


@router.get("/{slug}", response_model=RegistryEntryResponse, dependencies=[Depends(require_scopes("registry:read"))])
Expand Down
Loading
Loading