Skip to content

Commit fdadd80

Browse files
danielmillerpclaude
andcommitted
feat(agent_api_keys): one-call webhook-trigger setup endpoint
Add POST /agent_api_keys/webhook-trigger: registers a github/slack signature key for an agent and returns the ready-to-paste forward webhook URL + secret in one call. Bundles the existing key-create with webhook-URL composition so a UI (or a curl) can wire a trigger in a single step; the webhook then flows through the existing /agents/forward ingress that verifies the signature against this key. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 421f70d commit fdadd80

3 files changed

Lines changed: 219 additions & 0 deletions

File tree

agentex/src/api/routes/agent_api_keys.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import os
12
import secrets
23

34
from fastapi import APIRouter, HTTPException, Query
@@ -7,6 +8,8 @@
78
AgentAPIKey,
89
CreateAPIKeyRequest,
910
CreateAPIKeyResponse,
11+
CreateWebhookTriggerRequest,
12+
CreateWebhookTriggerResponse,
1013
)
1114
from src.api.schemas.authorization_types import (
1215
AgentexResourceType,
@@ -93,6 +96,74 @@ async def create_api_key(
9396
)
9497

9598

99+
@router.post(
100+
"/webhook-trigger",
101+
response_model=CreateWebhookTriggerResponse,
102+
)
103+
async def create_webhook_trigger(
104+
request: CreateWebhookTriggerRequest,
105+
agent_api_key_use_case: DAgentAPIKeysUseCase,
106+
agent_use_case: DAgentsUseCase,
107+
authorization_service: DAuthorizationService,
108+
) -> CreateWebhookTriggerResponse:
109+
"""Wire a webhook trigger in one call.
110+
111+
Registers the source's signature-verification key (github/slack) for the agent and
112+
returns the ready-to-paste forward webhook URL plus the signing secret (shown once).
113+
The webhook then flows through the existing /agents/forward ingress, which verifies
114+
the signature against this key. Bundles the existing key-create + URL composition so
115+
a UI (or a curl) can set up a trigger without two steps.
116+
"""
117+
if request.source not in (AgentAPIKeyType.GITHUB, AgentAPIKeyType.SLACK):
118+
raise HTTPException(
119+
status_code=400,
120+
detail="source must be 'github' or 'slack' for a webhook trigger.",
121+
)
122+
agent = await agent_use_case.get(name=request.agent_name)
123+
124+
# No api_key resource exists yet, so gate on the parent agent (update).
125+
await _check_agent_or_collapse_to_404(
126+
authorization_service,
127+
agent.id,
128+
AuthorizedOperationType.update,
129+
)
130+
131+
existing_api_key = await agent_api_key_use_case.get_by_agent_id_and_name(
132+
agent_id=agent.id,
133+
name=request.name,
134+
api_key_type=request.source,
135+
)
136+
if existing_api_key:
137+
error_msg = f"{request.source} webhook key '{request.name}' already exists for agent ID {agent.id}."
138+
logger.error(error_msg)
139+
raise HTTPException(status_code=409, detail=error_msg)
140+
141+
secret = request.secret or secrets.token_hex(32)
142+
agent_api_key_entity = await agent_api_key_use_case.create(
143+
agent_id=agent.id,
144+
api_key=str(secret),
145+
name=request.name,
146+
api_key_type=request.source,
147+
)
148+
149+
forward_path = request.forward_path.lstrip("/")
150+
webhook_path = f"/agents/forward/name/{request.agent_name}/{forward_path}"
151+
base_url = (request.base_url or os.environ.get("AGENTEX_PUBLIC_URL", "")).rstrip(
152+
"/"
153+
)
154+
webhook_url = f"{base_url}{webhook_path}" if base_url else None
155+
156+
return CreateWebhookTriggerResponse(
157+
key_id=agent_api_key_entity.id,
158+
agent_name=request.agent_name,
159+
source=agent_api_key_entity.api_key_type,
160+
name=request.name,
161+
secret=str(secret),
162+
webhook_path=webhook_path,
163+
webhook_url=webhook_url,
164+
)
165+
166+
96167
@router.get(
97168
"",
98169
response_model=list[AgentAPIKey],

agentex/src/api/schemas/agent_api_keys.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,53 @@ class CreateAPIKeyResponse(BaseModel):
5353
...,
5454
description="The value of the newly created API key.",
5555
)
56+
57+
58+
class CreateWebhookTriggerRequest(BaseModel):
59+
"""One-call setup for a webhook trigger: register the source's signature key and
60+
get back the ready-to-paste forward webhook URL."""
61+
62+
agent_name: str = Field(..., description="The agent the webhook drives.")
63+
source: AgentAPIKeyType = Field(
64+
AgentAPIKeyType.GITHUB,
65+
description="Webhook source whose signature is verified (github or slack).",
66+
)
67+
name: str = Field(
68+
...,
69+
description="Signature-lookup key: the repo full_name (github) or api_app_id "
70+
"(slack) that the forward ingress matches the incoming webhook against.",
71+
)
72+
forward_path: str = Field(
73+
...,
74+
description="Subpath the agent's own route handles, e.g. 'github-pr/<config-id>'. "
75+
"Appended to /agents/forward/name/{agent_name}/ to form the webhook URL.",
76+
)
77+
secret: str | None = Field(
78+
None,
79+
description="Optional signing secret; if unset, one is generated and returned.",
80+
)
81+
base_url: str | None = Field(
82+
None,
83+
description="Optional public agentex base URL for the returned webhook_url; "
84+
"defaults to the AGENTEX_PUBLIC_URL env var.",
85+
)
86+
87+
88+
class CreateWebhookTriggerResponse(BaseModel):
89+
key_id: str = Field(..., description="The created agent API key id.")
90+
agent_name: str = Field(..., description="The agent the webhook drives.")
91+
source: AgentAPIKeyType = Field(
92+
..., description="Webhook source (github or slack)."
93+
)
94+
name: str = Field(
95+
..., description="Signature-lookup key (repo full_name / api_app_id)."
96+
)
97+
secret: str = Field(
98+
...,
99+
description="The signing secret — shown once; paste into the source's webhook config.",
100+
)
101+
webhook_path: str = Field(..., description="The forward path to POST webhooks to.")
102+
webhook_url: str | None = Field(
103+
None,
104+
description="Full webhook URL to paste into the source (None if no base URL configured).",
105+
)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""Unit tests for the POST /agent_api_keys/webhook-trigger convenience endpoint."""
2+
3+
from __future__ import annotations
4+
5+
from unittest.mock import AsyncMock, MagicMock
6+
7+
import pytest
8+
import src.api.routes.agent_api_keys as mod
9+
from fastapi import HTTPException
10+
from src.api.routes.agent_api_keys import create_webhook_trigger
11+
from src.api.schemas.agent_api_keys import CreateWebhookTriggerRequest
12+
from src.domain.entities.agent_api_keys import AgentAPIKeyType
13+
14+
15+
@pytest.mark.unit
16+
@pytest.mark.asyncio
17+
class TestCreateWebhookTrigger:
18+
async def _call(self, monkeypatch, request, *, existing=None, base_env=None):
19+
# The agent-authorization helper does real authz work; stub it to a no-op.
20+
monkeypatch.setattr(mod, "_check_agent_or_collapse_to_404", AsyncMock())
21+
if base_env is not None:
22+
monkeypatch.setenv("AGENTEX_PUBLIC_URL", base_env)
23+
else:
24+
monkeypatch.delenv("AGENTEX_PUBLIC_URL", raising=False)
25+
26+
agent_use_case = MagicMock()
27+
agent_use_case.get = AsyncMock(return_value=MagicMock(id="agent-1"))
28+
29+
akuc = MagicMock()
30+
akuc.get_by_agent_id_and_name = AsyncMock(return_value=existing)
31+
akuc.create = AsyncMock(
32+
return_value=MagicMock(id="key-1", api_key_type=request.source)
33+
)
34+
35+
resp = await create_webhook_trigger(
36+
request=request,
37+
agent_api_key_use_case=akuc,
38+
agent_use_case=agent_use_case,
39+
authorization_service=MagicMock(),
40+
)
41+
return resp, akuc
42+
43+
async def test_creates_key_and_composes_url(self, monkeypatch):
44+
req = CreateWebhookTriggerRequest(
45+
agent_name="golden-agent",
46+
source=AgentAPIKeyType.GITHUB,
47+
name="acme/widgets",
48+
forward_path="github-pr/cfg-9",
49+
)
50+
resp, akuc = await self._call(
51+
monkeypatch, req, base_env="https://sgp.example.com"
52+
)
53+
54+
assert len(resp.secret) >= 32 # auto-generated signing secret
55+
assert (
56+
resp.webhook_url
57+
== "https://sgp.example.com/agents/forward/name/golden-agent/github-pr/cfg-9"
58+
)
59+
assert resp.webhook_path == "/agents/forward/name/golden-agent/github-pr/cfg-9"
60+
assert resp.source == AgentAPIKeyType.GITHUB
61+
# key registered under the signature-lookup name + type
62+
assert akuc.create.await_args.kwargs["name"] == "acme/widgets"
63+
assert akuc.create.await_args.kwargs["api_key_type"] == AgentAPIKeyType.GITHUB
64+
assert akuc.create.await_args.kwargs["api_key"] == resp.secret
65+
66+
async def test_uses_provided_secret_and_no_url_without_base(self, monkeypatch):
67+
req = CreateWebhookTriggerRequest(
68+
agent_name="a",
69+
source=AgentAPIKeyType.GITHUB,
70+
name="o/r",
71+
forward_path="gh",
72+
secret="mysecret",
73+
)
74+
resp, _ = await self._call(monkeypatch, req)
75+
assert resp.secret == "mysecret"
76+
assert resp.webhook_url is None # no AGENTEX_PUBLIC_URL configured
77+
assert resp.webhook_path == "/agents/forward/name/a/gh"
78+
79+
async def test_conflict_when_key_exists(self, monkeypatch):
80+
req = CreateWebhookTriggerRequest(
81+
agent_name="a", source=AgentAPIKeyType.GITHUB, name="o/r", forward_path="gh"
82+
)
83+
with pytest.raises(HTTPException) as exc:
84+
await self._call(monkeypatch, req, existing=MagicMock())
85+
assert exc.value.status_code == 409
86+
87+
async def test_rejects_non_webhook_source(self):
88+
req = CreateWebhookTriggerRequest(
89+
agent_name="a", source=AgentAPIKeyType.EXTERNAL, name="x", forward_path="gh"
90+
)
91+
with pytest.raises(HTTPException) as exc:
92+
await create_webhook_trigger(
93+
request=req,
94+
agent_api_key_use_case=MagicMock(),
95+
agent_use_case=MagicMock(),
96+
authorization_service=MagicMock(),
97+
)
98+
assert exc.value.status_code == 400

0 commit comments

Comments
 (0)