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
128 changes: 128 additions & 0 deletions agentex/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2722,6 +2722,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:
Expand Down Expand Up @@ -4549,6 +4590,93 @@ 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/<config-id>'.
Appended to /agents/forward/name/{agent_name}/ to form the webhook URL.
secret:
anyOf:
- type: string
- type: 'null'
title: Secret
description: Signing secret. For GitHub, omit to generate one, or provide
an existing webhook secret. For Slack, this is required and must be the
Slack app's Signing Secret.
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:
Expand Down
101 changes: 101 additions & 0 deletions agentex/src/api/routes/agent_api_keys.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import os
import secrets
from urllib.parse import quote

from fastapi import APIRouter, HTTPException, Query

Expand All @@ -7,6 +9,8 @@
AgentAPIKey,
CreateAPIKeyRequest,
CreateAPIKeyResponse,
CreateWebhookTriggerRequest,
CreateWebhookTriggerResponse,
)
from src.api.schemas.authorization_types import (
AgentexResourceType,
Expand Down Expand Up @@ -35,6 +39,10 @@
)


def _has_control_chars(value: str) -> bool:
return any(ord(char) < 32 or ord(char) == 127 for char in value)


@router.post(
"",
response_model=CreateAPIKeyResponse,
Expand Down Expand Up @@ -93,6 +101,99 @@ 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):
Comment thread
danielmillerp marked this conversation as resolved.
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.",
)

# 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."
),
)

forward_path = request.forward_path.lstrip("/")
if _has_control_chars(forward_path):
raise HTTPException(
status_code=400,
detail="forward_path must not contain control characters.",
)

secret = request.secret if request.secret is not None else 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,
)

encoded_agent_name = quote(request.agent_name, safe="")
encoded_forward_path = quote(forward_path, safe="/")
webhook_path = f"/agents/forward/name/{encoded_agent_name}/{encoded_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],
Expand Down
54 changes: 54 additions & 0 deletions agentex/src/api/schemas/agent_api_keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,57 @@ 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/<config-id>'. "
"Appended to /agents/forward/name/{agent_name}/ to form the webhook URL.",
)
secret: str | None = Field(
None,
description=(
"Signing secret. For GitHub, omit to generate one, or provide an existing "
"webhook secret. For Slack, this is required and must be the Slack app's "
"Signing Secret."
),
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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).",
)
Loading
Loading