Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""add build_only agent status

Revision ID: c7a1b2d3e4f5
Revises: 6c942325c828
Create Date: 2026-05-29 12:00:00.000000

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision: str = 'c7a1b2d3e4f5'
down_revision: Union[str, None] = '6c942325c828'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.execute("""
ALTER TYPE agentstatus ADD VALUE IF NOT EXISTS 'BUILD_ONLY';
""")
# ### end Alembic commands ###


def downgrade() -> None:
# Postgres does not support removing a value from an enum type, so there is
# nothing to do on downgrade (mirrors the add_unhealthy_status migration).
# ### end Alembic commands ###
pass
72 changes: 72 additions & 0 deletions agentex/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,35 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/agents/register-build:
post:
tags:
- Agents
summary: Register Build
description: Register an agent at build time, before it is deployed. Creates
the agent row in BUILD_ONLY status without an acp_url (there is no running
pod yet) so it can be permissioned and shared prior to deploy. Unlike /register,
this does not mint an API key. Idempotent by name.
operationId: register_build_agents_register_build_post
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/RegisterBuildRequest'
required: true
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/Agent'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/agents/forward/name/{agent_name}/{path}:
get:
tags:
Expand Down Expand Up @@ -3894,6 +3923,7 @@ components:
- Unknown
- Deleted
- Unhealthy
- BuildOnly
title: AgentStatus
AgentTaskTracker:
properties:
Expand Down Expand Up @@ -5292,6 +5322,48 @@ components:
- updated_at
title: RegisterAgentResponse
description: Response model for registering an agent.
RegisterBuildRequest:
properties:
name:
type: string
pattern: ^[a-z0-9-]+$
title: Name
description: The unique name of the agent.
description:
type: string
title: Description
description: The description of the agent.
principal_context:
anyOf:
- {}
- type: 'null'
title: Principal Context
description: Principal used for authorization
registration_metadata:
anyOf:
- additionalProperties: true
type: object
- type: 'null'
title: Registration Metadata
description: The metadata for the agent's build registration.
agent_input_type:
anyOf:
- $ref: '#/components/schemas/AgentInputType'
- type: 'null'
description: The type of input the agent expects.
type: object
required:
- name
- description
title: RegisterBuildRequest
description: 'Request model for registering an agent at build time (pre-deploy).


Unlike RegisterAgentRequest, there is no acp_url (the agent is not running

yet) and no acp_type is required. The created agent is left in BUILD_ONLY

status so it can be permissioned/shared before it is deployed.'
RehydrateTaskRequest:
properties:
task_id:
Expand Down
46 changes: 45 additions & 1 deletion agentex/src/api/routes/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@
from pydantic import ValidationError

from src.adapters.crud_store.exceptions import ItemDoesNotExist
from src.api.schemas.agents import Agent, RegisterAgentRequest, RegisterAgentResponse
from src.api.schemas.agents import (
Agent,
RegisterAgentRequest,
RegisterAgentResponse,
RegisterBuildRequest,
)
from src.api.schemas.agents_rpc import (
AgentRPCRequest,
AgentRPCResponse,
Expand Down Expand Up @@ -216,6 +221,45 @@ async def register_agent(
raise HTTPException(status_code=400, detail=str(e)) from e


@router.post(
"/register-build",
response_model=Agent,
summary="Register Build",
description=(
"Register an agent at build time, before it is deployed. Creates the "
"agent row in BUILD_ONLY status without an acp_url (there is no running "
"pod yet) so it can be permissioned and shared prior to deploy. Unlike "
"/register, this does not mint an API key. Idempotent by name."
),
)
async def register_build(
request: RegisterBuildRequest,
agents_use_case: DAgentsUseCase,
authorization_service: DAuthorizationService,
) -> Agent:
"""Create a build-only agent row and grant the caller access to it."""
await authorization_service.check(
AgentexResource.agent("*"),
AuthorizedOperationType.create,
principal_context=request.principal_context,
)
logger.info(f"Registering build for agent: {request.name}")
try:
agent_entity = await agents_use_case.register_build(
name=request.name,
description=request.description,
registration_metadata=request.registration_metadata,
agent_input_type=request.agent_input_type,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
await authorization_service.grant(
AgentexResource.agent(agent_entity.id),
principal_context=request.principal_context,
)
return Agent.model_validate(agent_entity)
Comment on lines +256 to +260
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Grant issued unconditionally on idempotent return

authorization_service.grant is called regardless of whether the use case created a new agent or returned an existing one. In the idempotent path (register_build found an existing agent by name), the current caller is granted access to an agent they did not create — without any modification to that agent. A principal with wildcard create permission can claim a grant on any existing agent simply by knowing its name and calling this endpoint. The /register endpoint has a comparable pattern but requires a live acp_url, giving stronger proof of ownership; register-build has no such constraint, lowering the bar considerably.

Prompt To Fix With AI
This is a comment left during a code review.
Path: agentex/src/api/routes/agents.py
Line: 256-260

Comment:
**Grant issued unconditionally on idempotent return**

`authorization_service.grant` is called regardless of whether the use case created a new agent or returned an existing one. In the idempotent path (`register_build` found an existing agent by name), the current caller is granted access to an agent they did not create — without any modification to that agent. A principal with wildcard `create` permission can claim a grant on any existing agent simply by knowing its name and calling this endpoint. The `/register` endpoint has a comparable pattern but requires a live `acp_url`, giving stronger proof of ownership; `register-build` has no such constraint, lowering the bar considerably.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Cursor Fix in Claude Code Fix in Codex



@router.get(
"/forward/name/{agent_name}/{path:path}",
summary="Forward GET request to agent by name",
Expand Down
27 changes: 27 additions & 0 deletions agentex/src/api/schemas/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ class AgentStatus(str, Enum):
UNKNOWN = "Unknown"
DELETED = "Deleted"
UNHEALTHY = "Unhealthy"
# Agent row created at build time, before any deployment exists. It has no
# acp_url yet and is not routable; deploy-time registration flips it to READY.
BUILD_ONLY = "BuildOnly"


class ACPType(str, Enum):
Expand Down Expand Up @@ -103,3 +106,27 @@ class RegisterAgentResponse(Agent):
agent_api_key: str | None = Field(
None, description="The API key for the agent, if applicable."
)


class RegisterBuildRequest(BaseModel):
"""Request model for registering an agent at build time (pre-deploy).

Unlike RegisterAgentRequest, there is no acp_url (the agent is not running
yet) and no acp_type is required. The created agent is left in BUILD_ONLY
status so it can be permissioned/shared before it is deployed.
"""

name: str = Field(
..., pattern=r"^[a-z0-9-]+$", description="The unique name of the agent."
)
description: str = Field(..., description="The description of the agent.")
principal_context: Any | None = Field(
default=None, description="Principal used for authorization"
)
registration_metadata: dict[str, Any] | None = Field(
default=None,
description="The metadata for the agent's build registration.",
)
agent_input_type: AgentInputType | None = Field(
default=None, description="The type of input the agent expects."
)
3 changes: 3 additions & 0 deletions agentex/src/domain/entities/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ class AgentStatus(str, Enum):
UNKNOWN = "Unknown"
DELETED = "Deleted"
UNHEALTHY = "Unhealthy"
# Agent row created at build time, before any deployment exists. It has no
# acp_url yet and is not routable; deploy-time registration flips it to READY.
BUILD_ONLY = "BuildOnly"


class ACPType(str, Enum):
Expand Down
49 changes: 49 additions & 0 deletions agentex/src/domain/use_cases/agents_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,55 @@ async def register_agent(
await self.ensure_healthcheck_workflow(agent)
return agent

async def register_build(
self,
name: str,
description: str,
registration_metadata: dict[str, Any] | None = None,
agent_input_type: AgentInputType | None = None,
) -> AgentEntity:
"""
Create an agent row for a build, before any deployment exists.

Unlike register_agent, this does NOT populate acp_url (there is no
running pod yet) and leaves the agent in BUILD_ONLY status so it can be
permissioned/shared prior to deploy. Deploy-time registration later
flips the agent to READY and sets the acp_url.

Idempotent: if an agent with the same name already exists, it is
returned unchanged so that re-building an existing agent never clobbers
a live deployment's status or acp_url.
"""
try:
existing = await self.agent_repo.get(name=name)
logger.info(
f"Agent {name} already exists, returning existing agent for build"
)
return existing
except ItemDoesNotExist:
logger.info(f"Agent {name} not found, creating build-only agent")

agent = AgentEntity(
id=orm_id(),
name=name,
description=description,
status=AgentStatus.BUILD_ONLY,
status_reason="Agent build registered; not yet deployed.",
acp_url=None,
registration_metadata=registration_metadata,
agent_input_type=agent_input_type,
)
# If multiple builds for the same new agent race, the first wins and the
# rest re-fetch the persisted row instead of erroring.
try:
agent = await self.agent_repo.create(item=agent)
except DuplicateItemError:
logger.info(
f"Agent {name} was likely created in parallel, returning existing"
)
agent = await self.agent_repo.get(name=name)
return agent

async def complete_deployment_registration(
self,
agent: AgentEntity,
Expand Down
76 changes: 76 additions & 0 deletions agentex/tests/integration/api/agents/test_agents_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,82 @@ async def test_register_with_agent_id(self, isolated_client):
assert updated_agent_data["acp_type"] == "sync"
assert updated_agent_data["id"] == agent_data["id"]

@pytest.mark.asyncio
async def test_register_build_creates_build_only_agent(self, isolated_client):
"""register-build creates a BUILD_ONLY agent with no acp_url and no api key."""
response = await isolated_client.post(
"/agents/register-build",
json={
"name": "test-build-only-agent",
"description": "Created via register-build",
},
)
assert response.status_code == 200
agent_data = response.json()
assert agent_data["name"] == "test-build-only-agent"
assert agent_data["description"] == "Created via register-build"
assert agent_data["status"] == "BuildOnly"
assert agent_data["id"] is not None
# Minimal endpoint: no API key is minted at build time
assert "agent_api_key" not in agent_data
# No running pod yet, so acp_url must not be populated
assert agent_data.get("acp_url") is None

# And - the build-only agent is retrievable and listed like any agent
get_response = await isolated_client.get(f"/agents/{agent_data['id']}")
assert get_response.status_code == 200
assert get_response.json()["status"] == "BuildOnly"

@pytest.mark.asyncio
async def test_register_build_is_idempotent_by_name(self, isolated_client):
"""A second register-build for the same name returns the existing agent."""
payload = {
"name": "test-build-idempotent-agent",
"description": "first",
}
first = await isolated_client.post("/agents/register-build", json=payload)
assert first.status_code == 200
first_id = first.json()["id"]

second = await isolated_client.post(
"/agents/register-build",
json={**payload, "description": "second"},
)
assert second.status_code == 200
# Same row returned; an existing agent is not clobbered by a rebuild
assert second.json()["id"] == first_id
assert second.json()["status"] == "BuildOnly"

@pytest.mark.asyncio
async def test_build_only_agent_promoted_to_ready_on_register(
self, isolated_client
):
"""register-build then /register (with the agent_id) flips status to Ready."""
build = await isolated_client.post(
"/agents/register-build",
json={
"name": "test-build-then-deploy-agent",
"description": "build first",
},
)
assert build.status_code == 200
assert build.json()["status"] == "BuildOnly"
agent_id = build.json()["id"]

registered = await isolated_client.post(
"/agents/register",
json={
"agent_id": agent_id,
"name": "test-build-then-deploy-agent",
"description": "now deployed",
"acp_url": "http://test-acp-server:8000",
"acp_type": "async",
},
)
assert registered.status_code == 200
assert registered.json()["id"] == agent_id
assert registered.json()["status"] == "Ready"

@pytest.mark.asyncio
async def test_register_agent_success_and_retrieve(self, isolated_client):
"""Test agent registration and retrieval via API endpoints"""
Expand Down
Loading