From 6ca6f700bc6942f7a701de353668ac084e4a4fff Mon Sep 17 00:00:00 2001 From: Daniel Miller Date: Mon, 22 Jun 2026 01:15:43 -0400 Subject: [PATCH 1/2] 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) --- agentex/openapi.yaml | 126 ++++++++++++++++++ agentex/src/api/routes/agent_api_keys.py | 74 ++++++++++ agentex/src/api/schemas/agent_api_keys.py | 50 +++++++ .../tests/unit/api/test_webhook_trigger.py | 98 ++++++++++++++ 4 files changed, 348 insertions(+) create mode 100644 agentex/tests/unit/api/test_webhook_trigger.py diff --git a/agentex/openapi.yaml b/agentex/openapi.yaml index 100fb9b8..964a7671 100644 --- a/agentex/openapi.yaml +++ b/agentex/openapi.yaml @@ -2700,6 +2700,47 @@ paths: application/json: schema: $ref: '#/components/schemas/HTTPValidationError' + /agent_api_keys/webhook-trigger: + post: + tags: + - Agent APIKeys + summary: Create Webhook Trigger + description: 'Wire a webhook trigger in one call. + + + Registers the source''s signature-verification key (github/slack) for the + agent and + + returns the ready-to-paste forward webhook URL plus the signing secret (shown + once). + + The webhook then flows through the existing /agents/forward ingress, which + verifies + + the signature against this key. Bundles the existing key-create + URL composition + so + + a UI (or a curl) can set up a trigger without two steps.' + operationId: create_webhook_trigger_agent_api_keys_webhook_trigger_post + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CreateWebhookTriggerRequest' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: + $ref: '#/components/schemas/CreateWebhookTriggerResponse' + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' /agent_api_keys/name/{name}: get: tags: @@ -4527,6 +4568,91 @@ components: to the agent inside the ACP payload for backward compatibility. type: object title: CreateTaskRequest + CreateWebhookTriggerRequest: + properties: + agent_name: + type: string + title: Agent Name + description: The agent the webhook drives. + source: + $ref: '#/components/schemas/AgentAPIKeyType' + description: Webhook source whose signature is verified (github or slack). + default: github + name: + type: string + title: Name + description: 'Signature-lookup key: the repo full_name (github) or api_app_id + (slack) that the forward ingress matches the incoming webhook against.' + forward_path: + type: string + title: Forward Path + description: Subpath the agent's own route handles, e.g. 'github-pr/'. + Appended to /agents/forward/name/{agent_name}/ to form the webhook URL. + secret: + anyOf: + - type: string + - type: 'null' + title: Secret + description: Optional signing secret; if unset, one is generated and returned. + base_url: + anyOf: + - type: string + - type: 'null' + title: Base Url + description: Optional public agentex base URL for the returned webhook_url; + defaults to the AGENTEX_PUBLIC_URL env var. + type: object + required: + - agent_name + - name + - forward_path + title: CreateWebhookTriggerRequest + description: 'One-call setup for a webhook trigger: register the source''s signature + key and + + get back the ready-to-paste forward webhook URL.' + CreateWebhookTriggerResponse: + properties: + key_id: + type: string + title: Key Id + description: The created agent API key id. + agent_name: + type: string + title: Agent Name + description: The agent the webhook drives. + source: + $ref: '#/components/schemas/AgentAPIKeyType' + description: Webhook source (github or slack). + name: + type: string + title: Name + description: Signature-lookup key (repo full_name / api_app_id). + secret: + type: string + title: Secret + description: The signing secret — shown once; paste into the source's webhook + config. + webhook_path: + type: string + title: Webhook Path + description: The forward path to POST webhooks to. + webhook_url: + anyOf: + - type: string + - type: 'null' + title: Webhook Url + description: Full webhook URL to paste into the source (None if no base + URL configured). + type: object + required: + - key_id + - agent_name + - source + - name + - secret + - webhook_path + title: CreateWebhookTriggerResponse DataContent: properties: type: diff --git a/agentex/src/api/routes/agent_api_keys.py b/agentex/src/api/routes/agent_api_keys.py index b7caebbe..e853ea62 100644 --- a/agentex/src/api/routes/agent_api_keys.py +++ b/agentex/src/api/routes/agent_api_keys.py @@ -1,3 +1,4 @@ +import os import secrets from fastapi import APIRouter, HTTPException, Query @@ -7,6 +8,8 @@ AgentAPIKey, CreateAPIKeyRequest, CreateAPIKeyResponse, + CreateWebhookTriggerRequest, + CreateWebhookTriggerResponse, ) from src.api.schemas.authorization_types import ( AgentexResourceType, @@ -93,6 +96,77 @@ async def create_api_key( ) +@router.post( + "/webhook-trigger", + response_model=CreateWebhookTriggerResponse, +) +async def create_webhook_trigger( + request: CreateWebhookTriggerRequest, + agent_api_key_use_case: DAgentAPIKeysUseCase, + agent_use_case: DAgentsUseCase, + authorization_service: DAuthorizationService, +) -> CreateWebhookTriggerResponse: + """Wire a webhook trigger in one call. + + Registers the source's signature-verification key (github/slack) for the agent and + returns the ready-to-paste forward webhook URL plus the signing secret (shown once). + The webhook then flows through the existing /agents/forward ingress, which verifies + the signature against this key. Bundles the existing key-create + URL composition so + a UI (or a curl) can set up a trigger without two steps. + """ + if request.source not in (AgentAPIKeyType.GITHUB, AgentAPIKeyType.SLACK): + raise HTTPException( + status_code=400, + detail="source must be 'github' or 'slack' for a webhook trigger.", + ) + agent = await agent_use_case.get(name=request.agent_name) + + # No api_key resource exists yet, so gate on the parent agent (update). + await _check_agent_or_collapse_to_404( + authorization_service, + agent.id, + AuthorizedOperationType.update, + ) + + existing_api_key = await agent_api_key_use_case.get_by_agent_id_and_name( + agent_id=agent.id, + name=request.name, + api_key_type=request.source, + ) + if existing_api_key: + # A duplicate is an expected client condition (409), not a server error, and the + # message avoids leaking the internal agent UUID the caller never supplied. + raise HTTPException( + status_code=409, + detail=f"A {request.source} webhook key named '{request.name}' already exists for this agent.", + ) + + secret = request.secret or secrets.token_hex(32) + agent_api_key_entity = await agent_api_key_use_case.create( + agent_id=agent.id, + api_key=str(secret), + name=request.name, + api_key_type=request.source, + ) + + forward_path = request.forward_path.lstrip("/") + webhook_path = f"/agents/forward/name/{request.agent_name}/{forward_path}" + base_url = (request.base_url or os.environ.get("AGENTEX_PUBLIC_URL", "")).rstrip( + "/" + ) + webhook_url = f"{base_url}{webhook_path}" if base_url else None + + return CreateWebhookTriggerResponse( + key_id=agent_api_key_entity.id, + agent_name=request.agent_name, + source=agent_api_key_entity.api_key_type, + name=request.name, + secret=str(secret), + webhook_path=webhook_path, + webhook_url=webhook_url, + ) + + @router.get( "", response_model=list[AgentAPIKey], diff --git a/agentex/src/api/schemas/agent_api_keys.py b/agentex/src/api/schemas/agent_api_keys.py index cbd3aefc..481ae1b3 100644 --- a/agentex/src/api/schemas/agent_api_keys.py +++ b/agentex/src/api/schemas/agent_api_keys.py @@ -53,3 +53,53 @@ class CreateAPIKeyResponse(BaseModel): ..., description="The value of the newly created API key.", ) + + +class CreateWebhookTriggerRequest(BaseModel): + """One-call setup for a webhook trigger: register the source's signature key and + get back the ready-to-paste forward webhook URL.""" + + agent_name: str = Field(..., description="The agent the webhook drives.") + source: AgentAPIKeyType = Field( + AgentAPIKeyType.GITHUB, + description="Webhook source whose signature is verified (github or slack).", + ) + name: str = Field( + ..., + description="Signature-lookup key: the repo full_name (github) or api_app_id " + "(slack) that the forward ingress matches the incoming webhook against.", + ) + forward_path: str = Field( + ..., + description="Subpath the agent's own route handles, e.g. 'github-pr/'. " + "Appended to /agents/forward/name/{agent_name}/ to form the webhook URL.", + ) + secret: str | None = Field( + None, + description="Optional signing secret; if unset, one is generated and returned.", + ) + base_url: str | None = Field( + None, + description="Optional public agentex base URL for the returned webhook_url; " + "defaults to the AGENTEX_PUBLIC_URL env var.", + ) + + +class CreateWebhookTriggerResponse(BaseModel): + key_id: str = Field(..., description="The created agent API key id.") + agent_name: str = Field(..., description="The agent the webhook drives.") + source: AgentAPIKeyType = Field( + ..., description="Webhook source (github or slack)." + ) + name: str = Field( + ..., description="Signature-lookup key (repo full_name / api_app_id)." + ) + secret: str = Field( + ..., + description="The signing secret — shown once; paste into the source's webhook config.", + ) + webhook_path: str = Field(..., description="The forward path to POST webhooks to.") + webhook_url: str | None = Field( + None, + description="Full webhook URL to paste into the source (None if no base URL configured).", + ) diff --git a/agentex/tests/unit/api/test_webhook_trigger.py b/agentex/tests/unit/api/test_webhook_trigger.py new file mode 100644 index 00000000..12de0a9c --- /dev/null +++ b/agentex/tests/unit/api/test_webhook_trigger.py @@ -0,0 +1,98 @@ +"""Unit tests for the POST /agent_api_keys/webhook-trigger convenience endpoint.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest +import src.api.routes.agent_api_keys as mod +from fastapi import HTTPException +from src.api.routes.agent_api_keys import create_webhook_trigger +from src.api.schemas.agent_api_keys import CreateWebhookTriggerRequest +from src.domain.entities.agent_api_keys import AgentAPIKeyType + + +@pytest.mark.unit +@pytest.mark.asyncio +class TestCreateWebhookTrigger: + async def _call(self, monkeypatch, request, *, existing=None, base_env=None): + # The agent-authorization helper does real authz work; stub it to a no-op. + monkeypatch.setattr(mod, "_check_agent_or_collapse_to_404", AsyncMock()) + if base_env is not None: + monkeypatch.setenv("AGENTEX_PUBLIC_URL", base_env) + else: + monkeypatch.delenv("AGENTEX_PUBLIC_URL", raising=False) + + agent_use_case = MagicMock() + agent_use_case.get = AsyncMock(return_value=MagicMock(id="agent-1")) + + akuc = MagicMock() + akuc.get_by_agent_id_and_name = AsyncMock(return_value=existing) + akuc.create = AsyncMock( + return_value=MagicMock(id="key-1", api_key_type=request.source) + ) + + resp = await create_webhook_trigger( + request=request, + agent_api_key_use_case=akuc, + agent_use_case=agent_use_case, + authorization_service=MagicMock(), + ) + return resp, akuc + + async def test_creates_key_and_composes_url(self, monkeypatch): + req = CreateWebhookTriggerRequest( + agent_name="golden-agent", + source=AgentAPIKeyType.GITHUB, + name="acme/widgets", + forward_path="github-pr/cfg-9", + ) + resp, akuc = await self._call( + monkeypatch, req, base_env="https://sgp.example.com" + ) + + assert len(resp.secret) >= 32 # auto-generated signing secret + assert ( + resp.webhook_url + == "https://sgp.example.com/agents/forward/name/golden-agent/github-pr/cfg-9" + ) + assert resp.webhook_path == "/agents/forward/name/golden-agent/github-pr/cfg-9" + assert resp.source == AgentAPIKeyType.GITHUB + # key registered under the signature-lookup name + type + assert akuc.create.await_args.kwargs["name"] == "acme/widgets" + assert akuc.create.await_args.kwargs["api_key_type"] == AgentAPIKeyType.GITHUB + assert akuc.create.await_args.kwargs["api_key"] == resp.secret + + async def test_uses_provided_secret_and_no_url_without_base(self, monkeypatch): + req = CreateWebhookTriggerRequest( + agent_name="a", + source=AgentAPIKeyType.GITHUB, + name="o/r", + forward_path="gh", + secret="mysecret", + ) + resp, _ = await self._call(monkeypatch, req) + assert resp.secret == "mysecret" + assert resp.webhook_url is None # no AGENTEX_PUBLIC_URL configured + assert resp.webhook_path == "/agents/forward/name/a/gh" + + async def test_conflict_when_key_exists(self, monkeypatch): + req = CreateWebhookTriggerRequest( + agent_name="a", source=AgentAPIKeyType.GITHUB, name="o/r", forward_path="gh" + ) + with pytest.raises(HTTPException) as exc: + await self._call(monkeypatch, req, existing=MagicMock()) + assert exc.value.status_code == 409 + + async def test_rejects_non_webhook_source(self): + req = CreateWebhookTriggerRequest( + agent_name="a", source=AgentAPIKeyType.EXTERNAL, name="x", forward_path="gh" + ) + with pytest.raises(HTTPException) as exc: + await create_webhook_trigger( + request=req, + agent_api_key_use_case=MagicMock(), + agent_use_case=MagicMock(), + authorization_service=MagicMock(), + ) + assert exc.value.status_code == 400 From 949cc2d3ae1df2e098bb4106d01a2e329e2da0a0 Mon Sep 17 00:00:00 2001 From: Daniel Miller Date: Mon, 22 Jun 2026 22:03:21 -0400 Subject: [PATCH 2/2] fix(agent_api_keys): require provided secret for Slack triggers Slack signs requests with the app's existing Signing Secret, not a per-webhook secret we can generate. Auto-generating one for a Slack trigger stored a random value that would never match, so every real Slack delivery failed signature verification. Now Slack requires the caller to supply 'secret'; GitHub still auto-generates. Addresses Greptile P1. Co-Authored-By: Claude Opus 4.8 (1M context) --- agentex/src/api/routes/agent_api_keys.py | 14 ++++++++++++ .../tests/unit/api/test_webhook_trigger.py | 22 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/agentex/src/api/routes/agent_api_keys.py b/agentex/src/api/routes/agent_api_keys.py index e853ea62..aaf894da 100644 --- a/agentex/src/api/routes/agent_api_keys.py +++ b/agentex/src/api/routes/agent_api_keys.py @@ -141,6 +141,20 @@ async def create_webhook_trigger( detail=f"A {request.source} webhook key named '{request.name}' already exists for this agent.", ) + # GitHub lets you supply (and we can generate) a per-webhook secret to paste into the + # repo's Secret field. Slack is different: it signs every request with the app's own + # Signing Secret, so the caller must supply that exact value — a generated one would + # never match, and validate_slack_delivery_webhook would reject every real delivery. + # (See PR #329 discussion.) + if request.source == AgentAPIKeyType.SLACK and not request.secret: + raise HTTPException( + status_code=400, + detail=( + "Slack triggers must supply 'secret' set to the Slack app's Signing Secret " + "(from your app credentials); it can't be generated." + ), + ) + secret = request.secret or secrets.token_hex(32) agent_api_key_entity = await agent_api_key_use_case.create( agent_id=agent.id, diff --git a/agentex/tests/unit/api/test_webhook_trigger.py b/agentex/tests/unit/api/test_webhook_trigger.py index 12de0a9c..34063d0a 100644 --- a/agentex/tests/unit/api/test_webhook_trigger.py +++ b/agentex/tests/unit/api/test_webhook_trigger.py @@ -96,3 +96,25 @@ async def test_rejects_non_webhook_source(self): authorization_service=MagicMock(), ) assert exc.value.status_code == 400 + + async def test_slack_without_secret_rejected(self, monkeypatch): + # Slack signs with the app's existing Signing Secret — we can't generate one, + # so omitting it must 400 rather than store a random value that never matches. + req = CreateWebhookTriggerRequest( + agent_name="a", source=AgentAPIKeyType.SLACK, name="my-app", forward_path="slack" + ) + with pytest.raises(HTTPException) as exc: + await self._call(monkeypatch, req) + assert exc.value.status_code == 400 + + async def test_slack_with_provided_secret_ok(self, monkeypatch): + req = CreateWebhookTriggerRequest( + agent_name="a", + source=AgentAPIKeyType.SLACK, + name="my-app", + forward_path="slack", + secret="slack-signing-secret", + ) + resp, akuc = await self._call(monkeypatch, req) + assert resp.secret == "slack-signing-secret" + assert akuc.create.await_args.kwargs["api_key"] == "slack-signing-secret"