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
126 changes: 126 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,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/<config-id>'.
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:
Expand Down
88 changes: 88 additions & 0 deletions agentex/src/api/routes/agent_api_keys.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
import secrets

from fastapi import APIRouter, HTTPException, Query
Expand All @@ -7,6 +8,8 @@
AgentAPIKey,
CreateAPIKeyRequest,
CreateAPIKeyResponse,
CreateWebhookTriggerRequest,
CreateWebhookTriggerResponse,
)
from src.api.schemas.authorization_types import (
AgentexResourceType,
Expand Down Expand Up @@ -93,6 +96,91 @@ 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):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Are there other webhooks we want to scope out (linear, notion, pager duty, datadog?) Is there a way to make it easier to add a key than updating the code?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Deferring to follow-ups, keeping this PR scoped to github/slack. Each new source (Linear, Notion, PagerDuty, Datadog) needs its own signature-verification scheme wired into the forward path — Linear is HMAC-SHA256 like GitHub, but PagerDuty/Datadog/Notion differ — so it's real per-source work rather than just an enum value. When we add the next source we'll introduce a small {source: signature_scheme} registry so adding one becomes a single entry instead of scattered changes. Tracking that as the extension point.

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

secret = request.secret or secrets.token_hex(32)

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 Empty string for secret is falsy in Python, so request.secret or secrets.token_hex(32) will generate a new random secret rather than treating the empty string as-is. A caller who sends "secret": "" intending to set up a no-secret GitHub webhook (GitHub allows omitting the secret, in which case it sends no X-Hub-Signature-256 header) would receive a generated secret, register it on the key, but configure GitHub with no secret — causing every delivery to be rejected with an invalid-signature failure. Replacing or with an explicit None check avoids the silent coercion.

Suggested change
secret = request.secret or secrets.token_hex(32)
secret = request.secret if request.secret is not None else secrets.token_hex(32)
Prompt To Fix With AI
This is a comment left during a code review.
Path: agentex/src/api/routes/agent_api_keys.py
Line: 157

Comment:
Empty string for `secret` is falsy in Python, so `request.secret or secrets.token_hex(32)` will generate a new random secret rather than treating the empty string as-is. A caller who sends `"secret": ""` intending to set up a no-secret GitHub webhook (GitHub allows omitting the secret, in which case it sends no `X-Hub-Signature-256` header) would receive a generated secret, register it on the key, but configure GitHub with no secret — causing every delivery to be rejected with an invalid-signature failure. Replacing `or` with an explicit `None` check avoids the silent coercion.

```suggestion
    secret = request.secret if request.secret is not None else secrets.token_hex(32)
```

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

Fix in Cursor Fix in Claude Code Fix in Codex

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,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
)

forward_path = request.forward_path.lstrip("/")
webhook_path = f"/agents/forward/name/{request.agent_name}/{forward_path}"
Comment on lines +166 to +167

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 Raw path breaks routing

forward_path is inserted into the returned URL as raw text, so URL delimiters can change what the forward route receives. For example, forward_path="github-pr/cfg-9?mode=review" returns /agents/forward/name/<agent>/github-pr/cfg-9?mode=review; when GitHub posts to it, FastAPI receives github-pr/cfg-9 as the path and mode=review as the query string, so the agent never gets the configured subpath. #, spaces, and other reserved characters have similar effects. Encode the path segment or reject query, fragment, and control characters before returning the pasteable URL.

Artifacts

Repro: focused FastAPI pytest for raw forward_path delimiter routing

  • Contains supporting evidence from the run (text/x-python; charset=utf-8).

Repro: verbose pytest output showing webhook-trigger and forward route HTTP status and JSON responses

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

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

Comment:
**Raw path breaks routing**

`forward_path` is inserted into the returned URL as raw text, so URL delimiters can change what the forward route receives. For example, `forward_path="github-pr/cfg-9?mode=review"` returns `/agents/forward/name/<agent>/github-pr/cfg-9?mode=review`; when GitHub posts to it, FastAPI receives `github-pr/cfg-9` as the path and `mode=review` as the query string, so the agent never gets the configured subpath. `#`, spaces, and other reserved characters have similar effects. Encode the path segment or reject query, fragment, and control characters before returning the pasteable URL.

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

Fix in Cursor Fix in Claude Code Fix in Codex

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
50 changes: 50 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,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/<config-id>'. "
"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.",
)
Comment on lines +77 to +80

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 Slack contract is stale

The route now returns 400 when source is slack and secret is omitted, but this request schema still documents secret as optional and generated when unset. Generated clients and UI built from this schema can omit the Slack signing secret, then every Slack setup call fails instead of completing the one-call trigger flow.

Artifacts

Repro: FastAPI TestClient schema and endpoint harness

  • Contains supporting evidence from the run (text/x-python; charset=utf-8).

Repro: OpenAPI schema excerpt and Slack endpoint 400 response output

  • Keeps the command output available without making the summary code-heavy.

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: agentex/src/api/schemas/agent_api_keys.py
Line: 77-80

Comment:
**Slack contract is stale**

The route now returns `400` when `source` is `slack` and `secret` is omitted, but this request schema still documents `secret` as optional and generated when unset. Generated clients and UI built from this schema can omit the Slack signing secret, then every Slack setup call fails instead of completing the one-call trigger flow.

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

Fix in Cursor Fix in Claude Code Fix in Codex

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