Skip to content

Commit 6ca6f70

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 6ca6f70

4 files changed

Lines changed: 348 additions & 0 deletions

File tree

agentex/openapi.yaml

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2700,6 +2700,47 @@ paths:
27002700
application/json:
27012701
schema:
27022702
$ref: '#/components/schemas/HTTPValidationError'
2703+
/agent_api_keys/webhook-trigger:
2704+
post:
2705+
tags:
2706+
- Agent APIKeys
2707+
summary: Create Webhook Trigger
2708+
description: 'Wire a webhook trigger in one call.
2709+
2710+
2711+
Registers the source''s signature-verification key (github/slack) for the
2712+
agent and
2713+
2714+
returns the ready-to-paste forward webhook URL plus the signing secret (shown
2715+
once).
2716+
2717+
The webhook then flows through the existing /agents/forward ingress, which
2718+
verifies
2719+
2720+
the signature against this key. Bundles the existing key-create + URL composition
2721+
so
2722+
2723+
a UI (or a curl) can set up a trigger without two steps.'
2724+
operationId: create_webhook_trigger_agent_api_keys_webhook_trigger_post
2725+
requestBody:
2726+
content:
2727+
application/json:
2728+
schema:
2729+
$ref: '#/components/schemas/CreateWebhookTriggerRequest'
2730+
required: true
2731+
responses:
2732+
'200':
2733+
description: Successful Response
2734+
content:
2735+
application/json:
2736+
schema:
2737+
$ref: '#/components/schemas/CreateWebhookTriggerResponse'
2738+
'422':
2739+
description: Validation Error
2740+
content:
2741+
application/json:
2742+
schema:
2743+
$ref: '#/components/schemas/HTTPValidationError'
27032744
/agent_api_keys/name/{name}:
27042745
get:
27052746
tags:
@@ -4527,6 +4568,91 @@ components:
45274568
to the agent inside the ACP payload for backward compatibility.
45284569
type: object
45294570
title: CreateTaskRequest
4571+
CreateWebhookTriggerRequest:
4572+
properties:
4573+
agent_name:
4574+
type: string
4575+
title: Agent Name
4576+
description: The agent the webhook drives.
4577+
source:
4578+
$ref: '#/components/schemas/AgentAPIKeyType'
4579+
description: Webhook source whose signature is verified (github or slack).
4580+
default: github
4581+
name:
4582+
type: string
4583+
title: Name
4584+
description: 'Signature-lookup key: the repo full_name (github) or api_app_id
4585+
(slack) that the forward ingress matches the incoming webhook against.'
4586+
forward_path:
4587+
type: string
4588+
title: Forward Path
4589+
description: Subpath the agent's own route handles, e.g. 'github-pr/<config-id>'.
4590+
Appended to /agents/forward/name/{agent_name}/ to form the webhook URL.
4591+
secret:
4592+
anyOf:
4593+
- type: string
4594+
- type: 'null'
4595+
title: Secret
4596+
description: Optional signing secret; if unset, one is generated and returned.
4597+
base_url:
4598+
anyOf:
4599+
- type: string
4600+
- type: 'null'
4601+
title: Base Url
4602+
description: Optional public agentex base URL for the returned webhook_url;
4603+
defaults to the AGENTEX_PUBLIC_URL env var.
4604+
type: object
4605+
required:
4606+
- agent_name
4607+
- name
4608+
- forward_path
4609+
title: CreateWebhookTriggerRequest
4610+
description: 'One-call setup for a webhook trigger: register the source''s signature
4611+
key and
4612+
4613+
get back the ready-to-paste forward webhook URL.'
4614+
CreateWebhookTriggerResponse:
4615+
properties:
4616+
key_id:
4617+
type: string
4618+
title: Key Id
4619+
description: The created agent API key id.
4620+
agent_name:
4621+
type: string
4622+
title: Agent Name
4623+
description: The agent the webhook drives.
4624+
source:
4625+
$ref: '#/components/schemas/AgentAPIKeyType'
4626+
description: Webhook source (github or slack).
4627+
name:
4628+
type: string
4629+
title: Name
4630+
description: Signature-lookup key (repo full_name / api_app_id).
4631+
secret:
4632+
type: string
4633+
title: Secret
4634+
description: The signing secret — shown once; paste into the source's webhook
4635+
config.
4636+
webhook_path:
4637+
type: string
4638+
title: Webhook Path
4639+
description: The forward path to POST webhooks to.
4640+
webhook_url:
4641+
anyOf:
4642+
- type: string
4643+
- type: 'null'
4644+
title: Webhook Url
4645+
description: Full webhook URL to paste into the source (None if no base
4646+
URL configured).
4647+
type: object
4648+
required:
4649+
- key_id
4650+
- agent_name
4651+
- source
4652+
- name
4653+
- secret
4654+
- webhook_path
4655+
title: CreateWebhookTriggerResponse
45304656
DataContent:
45314657
properties:
45324658
type:

agentex/src/api/routes/agent_api_keys.py

Lines changed: 74 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,77 @@ 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+
# A duplicate is an expected client condition (409), not a server error, and the
138+
# message avoids leaking the internal agent UUID the caller never supplied.
139+
raise HTTPException(
140+
status_code=409,
141+
detail=f"A {request.source} webhook key named '{request.name}' already exists for this agent.",
142+
)
143+
144+
secret = request.secret or secrets.token_hex(32)
145+
agent_api_key_entity = await agent_api_key_use_case.create(
146+
agent_id=agent.id,
147+
api_key=str(secret),
148+
name=request.name,
149+
api_key_type=request.source,
150+
)
151+
152+
forward_path = request.forward_path.lstrip("/")
153+
webhook_path = f"/agents/forward/name/{request.agent_name}/{forward_path}"
154+
base_url = (request.base_url or os.environ.get("AGENTEX_PUBLIC_URL", "")).rstrip(
155+
"/"
156+
)
157+
webhook_url = f"{base_url}{webhook_path}" if base_url else None
158+
159+
return CreateWebhookTriggerResponse(
160+
key_id=agent_api_key_entity.id,
161+
agent_name=request.agent_name,
162+
source=agent_api_key_entity.api_key_type,
163+
name=request.name,
164+
secret=str(secret),
165+
webhook_path=webhook_path,
166+
webhook_url=webhook_url,
167+
)
168+
169+
96170
@router.get(
97171
"",
98172
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)