Skip to content

Commit 9cc71fb

Browse files
feat(agent_api_keys): one-call webhook-trigger setup endpoint (#329)
## What Adds `POST /agent_api_keys/webhook-trigger` — wires a webhook trigger in **one call**: - registers a `github`/`slack` signature-verification key for the agent (auto-generates the signing secret if not provided), - returns the ready-to-paste **forward webhook URL** + the **secret** (shown once). ``` POST /agent_api_keys/webhook-trigger { "agent_name": "golden-agent", "source": "github", "name": "<owner/repo>", "forward_path": "github-pr/<config-id>" } → { key_id, secret, webhook_path, webhook_url } ``` ## Why The pieces to trigger an agent from a webhook already exist (the `/agents/forward` ingress verifies the signature against an agent key, and `POST /agent_api_keys` registers keys). This bundles key-create + webhook-URL composition so a UI (or a curl) can set up a trigger in a single step instead of two — the backend for the self-serve "Add trigger" button. The webhook then flows through the existing forward ingress unchanged. **Before** — wiring a trigger was two manual steps, and the caller had to know the agent's internal id, invent a secret, and hand-compose the forward URL: ```python # 1. register the signing key (need the agent's internal id + your own secret) POST /agent_api_keys { "agent_id": "<look-up-first>", "api_key": "<generate-yourself>", "name": "owner/repo", "api_key_type": "github" } # 2. hand-build the forward URL from the convention you have to know webhook_url = f"{public_url}/agents/forward/name/{agent_name}/github-pr/{config_id}" ``` **After** — one call, by agent **name**, returns the URL + secret ready to paste: ```python POST /agent_api_keys/webhook-trigger { "agent_name": "golden-agent", "source": "github", "name": "owner/repo", "forward_path": "github-pr/<config-id>" } → { "key_id": "...", "secret": "ab3f…", # generated for you, shown once "webhook_path": "/agents/forward/name/golden-agent/github-pr/<config-id>", "webhook_url": "https://<host>/agents/forward/name/golden-agent/github-pr/<config-id>" } ``` No new ingress, no migration — built on the existing `agent_api_keys` + forward mechanism. ## On the signing secret This matches how GitHub webhooks actually work. A GitHub webhook's **Secret** is a value *you* supply (GitHub doesn't generate one) — you type a high-entropy string into the webhook's Secret field, and GitHub uses it to HMAC each payload into `X-Hub-Signature-256: sha256=…`, which the receiver re-computes and compares. It's a shared secret that must exist on both sides (GitHub's config + the verifying server), and it's optional-but-recommended (no secret → no signature header at all). Refs: GitHub docs [validating-webhook-deliveries](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries), [creating-webhooks](https://docs.github.com/en/webhooks/using-webhooks/creating-webhooks). Given that, the endpoint's `secret` handling is deliberate: - **omit `secret`** (default) → the endpoint generates a strong one (`secrets.token_hex(32)`), stores it on the agent's verification key, and returns it once to paste into GitHub's Secret field — so the caller never has to invent entropy. - **supply `secret`** → for when the GitHub webhook already has a secret set and the agent side just needs to match it. ## Testing - 4 unit tests (URL composition, provided vs generated secret, 409 on duplicate, 400 on non-webhook source). ruff clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- greptile_comment --> <h3>Greptile Summary</h3> Adds `POST /agent_api_keys/webhook-trigger`, a one-call endpoint that registers a GitHub/Slack signature-verification key for an agent and returns the ready-to-paste forward webhook URL and signing secret. Iterates on earlier review feedback (Slack secret enforcement, empty-string coercion fix, UUID leakage, log level, forward-path URL encoding). - **New endpoint + schemas**: `create_webhook_trigger` bundles key creation, `forward_path` URL encoding, and webhook URL composition into a single request; Slack sources require an explicit `secret`, GitHub auto-generates one if omitted. - **Validation & encoding**: `forward_path` control characters are rejected; `agent_name` and `forward_path` are percent-encoded with `urllib.parse.quote` before being embedded in the returned URL. - **Tests**: Eight unit tests covering the main flows, including URL encoding, Slack without secret, duplicate-key 409, and an empty-secret preserve check. <details><summary><h3>Confidence Score: 4/5</h3></summary> The new handler works correctly for all inputs except when forward_path contains a control character — in that case, the signing key is already committed to the database before the 400 is raised, leaving an orphaned key that blocks all retries with the same name. The forward_path control-character check runs after agent_api_key_use_case.create() rather than before it. A caller who sends a forward_path with a control character gets a 400 response and no key_id, while the key quietly persists. Any corrected retry then hits a 409 permanently. Moving the validation above the DB write is the full fix. All other previously raised concerns (Slack secret requirement, empty-string or coercion, UUID leak, log level) appear to have been addressed correctly. agentex/src/api/routes/agent_api_keys.py — the create_webhook_trigger handler needs the forward_path control-char check moved above agent_api_key_use_case.create(). </details> <details><summary><h3>Important Files Changed</h3></summary> | Filename | Overview | |----------|----------| | agentex/src/api/routes/agent_api_keys.py | New create_webhook_trigger handler correctly addresses prior review concerns (Slack secret, empty-string coercion, UUID leakage, log level), but has a sequencing bug: forward_path control-char validation fires after agent_api_key_use_case.create(), which can orphan a committed key and leave the caller unable to retry. | | agentex/src/api/schemas/agent_api_keys.py | New request/response schemas for webhook trigger; secret is str | None with updated description correctly documenting Slack requirement; no structural issues. | | agentex/tests/unit/api/test_webhook_trigger.py | Good unit-test coverage: URL composition, provided vs generated secret, 409 on duplicate, 400 on non-webhook source, Slack with/without secret, URL encoding, and control-char rejection. | | agentex/openapi.yaml | New /agent_api_keys/webhook-trigger path and schemas added; secret description updated to document Slack requirement; mirrors schema definitions correctly. | </details> </details> <details><summary><h3>Sequence Diagram</h3></summary> <a href="#gh-light-mode-only"> ```mermaid %%{init: {'theme': 'neutral'}}%% sequenceDiagram participant Caller participant Handler as create_webhook_trigger participant AuthSvc as AuthorizationService participant AgentUC as AgentsUseCase participant KeyUC as AgentAPIKeysUseCase Caller->>Handler: POST /agent_api_keys/webhook-trigger Handler->>Handler: validate source (github/slack) Handler->>AgentUC: "get(name=agent_name)" AgentUC-->>Handler: agent Handler->>AuthSvc: check agent update permission AuthSvc-->>Handler: ok / 404 Handler->>KeyUC: get_by_agent_id_and_name(...) KeyUC-->>Handler: existing key or None alt key exists Handler-->>Caller: 409 Conflict end Handler->>Handler: validate Slack requires secret Handler->>Handler: resolve secret (provided or token_hex) Handler->>KeyUC: create(agent_id, api_key, name, type) KeyUC-->>Handler: agent_api_key_entity Note over Handler: forward_path validated AFTER DB write Handler->>Handler: lstrip + control-char check Handler->>Handler: quote(agent_name) + quote(forward_path) Handler->>Handler: compose webhook_path + webhook_url Handler-->>Caller: "200 {key_id, secret, webhook_path, webhook_url}" ``` </a> <a href="#gh-dark-mode-only"> ```mermaid %%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% sequenceDiagram participant Caller participant Handler as create_webhook_trigger participant AuthSvc as AuthorizationService participant AgentUC as AgentsUseCase participant KeyUC as AgentAPIKeysUseCase Caller->>Handler: POST /agent_api_keys/webhook-trigger Handler->>Handler: validate source (github/slack) Handler->>AgentUC: "get(name=agent_name)" AgentUC-->>Handler: agent Handler->>AuthSvc: check agent update permission AuthSvc-->>Handler: ok / 404 Handler->>KeyUC: get_by_agent_id_and_name(...) KeyUC-->>Handler: existing key or None alt key exists Handler-->>Caller: 409 Conflict end Handler->>Handler: validate Slack requires secret Handler->>Handler: resolve secret (provided or token_hex) Handler->>KeyUC: create(agent_id, api_key, name, type) KeyUC-->>Handler: agent_api_key_entity Note over Handler: forward_path validated AFTER DB write Handler->>Handler: lstrip + control-char check Handler->>Handler: quote(agent_name) + quote(forward_path) Handler->>Handler: compose webhook_path + webhook_url Handler-->>Caller: "200 {key_id, secret, webhook_path, webhook_url}" ``` </a> </details> <a href="https://app.greptile.com/api/ide/cursor?prompt=Fix%20the%20following%201%20code%20review%20issue.%20Work%20through%20them%20one%20at%20a%20time%2C%20proposing%20concise%20fixes.%0A%0A---%0A%0A%23%23%23%20Issue%201%20of%201%0Aagentex%2Fsrc%2Fapi%2Froutes%2Fagent_api_keys.py%3A163-176%0A**Key%20created%20before%20%60forward_path%60%20is%20validated**%20%E2%80%94%20if%20%60forward_path%60%20contains%20a%20control%20character%2C%20the%20DB%20write%20at%20line%20164%20has%20already%20committed%20before%20the%20400%20is%20raised.%20The%20caller%20receives%20an%20error%20response%20with%20no%20%60key_id%60%2C%20yet%20the%20key%20now%20exists%20under%20%60%28agent_id%2C%20name%2C%20source%29%60.%20Any%20subsequent%20corrected%20retry%20hits%20a%20409%20and%20is%20permanently%20blocked%20without%20deleting%20the%20orphaned%20key.%20Move%20the%20control-character%20check%20%28and%20the%20%60lstrip%60%29%20to%20before%20the%20%60create%28%29%60%20call.%0A%0A%60%60%60suggestion%0A%20%20%20%20forward_path%20%3D%20request.forward_path.lstrip%28%22%2F%22%29%0A%20%20%20%20if%20_has_control_chars%28forward_path%29%3A%0A%20%20%20%20%20%20%20%20raise%20HTTPException%28%0A%20%20%20%20%20%20%20%20%20%20%20%20status_code%3D400%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20detail%3D%22forward_path%20must%20not%20contain%20control%20characters.%22%2C%0A%20%20%20%20%20%20%20%20%29%0A%0A%20%20%20%20secret%20%3D%20request.secret%20if%20request.secret%20is%20not%20None%20else%20secrets.token_hex%2832%29%0A%20%20%20%20agent_api_key_entity%20%3D%20await%20agent_api_key_use_case.create%28%0A%20%20%20%20%20%20%20%20agent_id%3Dagent.id%2C%0A%20%20%20%20%20%20%20%20api_key%3Dstr%28secret%29%2C%0A%20%20%20%20%20%20%20%20name%3Drequest.name%2C%0A%20%20%20%20%20%20%20%20api_key_type%3Drequest.source%2C%0A%20%20%20%20%29%0A%60%60%60%0A%0A&pr=329&platform=github"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCursorDark.svg?v=3"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCursor.svg?v=3"><img alt="Fix All in Cursor" src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCursor.svg?v=3" height="20"></picture></a> <a href="https://app.greptile.com/ide/claude-code?prompt=Fix%20the%20following%201%20code%20review%20issue.%20Work%20through%20them%20one%20at%20a%20time%2C%20proposing%20concise%20fixes.%0A%0A---%0A%0A%23%23%23%20Issue%201%20of%201%0Aagentex%2Fsrc%2Fapi%2Froutes%2Fagent_api_keys.py%3A163-176%0A**Key%20created%20before%20%60forward_path%60%20is%20validated**%20%E2%80%94%20if%20%60forward_path%60%20contains%20a%20control%20character%2C%20the%20DB%20write%20at%20line%20164%20has%20already%20committed%20before%20the%20400%20is%20raised.%20The%20caller%20receives%20an%20error%20response%20with%20no%20%60key_id%60%2C%20yet%20the%20key%20now%20exists%20under%20%60%28agent_id%2C%20name%2C%20source%29%60.%20Any%20subsequent%20corrected%20retry%20hits%20a%20409%20and%20is%20permanently%20blocked%20without%20deleting%20the%20orphaned%20key.%20Move%20the%20control-character%20check%20%28and%20the%20%60lstrip%60%29%20to%20before%20the%20%60create%28%29%60%20call.%0A%0A%60%60%60suggestion%0A%20%20%20%20forward_path%20%3D%20request.forward_path.lstrip%28%22%2F%22%29%0A%20%20%20%20if%20_has_control_chars%28forward_path%29%3A%0A%20%20%20%20%20%20%20%20raise%20HTTPException%28%0A%20%20%20%20%20%20%20%20%20%20%20%20status_code%3D400%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20detail%3D%22forward_path%20must%20not%20contain%20control%20characters.%22%2C%0A%20%20%20%20%20%20%20%20%29%0A%0A%20%20%20%20secret%20%3D%20request.secret%20if%20request.secret%20is%20not%20None%20else%20secrets.token_hex%2832%29%0A%20%20%20%20agent_api_key_entity%20%3D%20await%20agent_api_key_use_case.create%28%0A%20%20%20%20%20%20%20%20agent_id%3Dagent.id%2C%0A%20%20%20%20%20%20%20%20api_key%3Dstr%28secret%29%2C%0A%20%20%20%20%20%20%20%20name%3Drequest.name%2C%0A%20%20%20%20%20%20%20%20api_key_type%3Drequest.source%2C%0A%20%20%20%20%29%0A%60%60%60%0A%0A&repo=scaleapi%2Fscale-agentex&pr=329&platform=github"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInClaudeDark.svg?v=3"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInClaude.svg?v=3"><img alt="Fix All in Claude Code" src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInClaude.svg?v=3" height="20"></picture></a> <a href="https://app.greptile.com/api/ide/codex?prompt=IMPORTANT%3A%20Work%20in%20the%20repository%20%22scaleapi%2Fscale-agentex%22%20on%20the%20existing%20branch%20%22dm%2Fagentex-webhook-trigger%22.%20Checkout%20that%20branch%20%E2%80%94%20do%20NOT%20create%20a%20new%20branch%20or%20open%20a%20new%20PR.%20Push%20your%20changes%20to%20%22dm%2Fagentex-webhook-trigger%22.%0A%0AFix%20the%20following%201%20code%20review%20issue.%20Work%20through%20them%20one%20at%20a%20time%2C%20proposing%20concise%20fixes.%0A%0A---%0A%0A%23%23%23%20Issue%201%20of%201%0Aagentex%2Fsrc%2Fapi%2Froutes%2Fagent_api_keys.py%3A163-176%0A**Key%20created%20before%20%60forward_path%60%20is%20validated**%20%E2%80%94%20if%20%60forward_path%60%20contains%20a%20control%20character%2C%20the%20DB%20write%20at%20line%20164%20has%20already%20committed%20before%20the%20400%20is%20raised.%20The%20caller%20receives%20an%20error%20response%20with%20no%20%60key_id%60%2C%20yet%20the%20key%20now%20exists%20under%20%60%28agent_id%2C%20name%2C%20source%29%60.%20Any%20subsequent%20corrected%20retry%20hits%20a%20409%20and%20is%20permanently%20blocked%20without%20deleting%20the%20orphaned%20key.%20Move%20the%20control-character%20check%20%28and%20the%20%60lstrip%60%29%20to%20before%20the%20%60create%28%29%60%20call.%0A%0A%60%60%60suggestion%0A%20%20%20%20forward_path%20%3D%20request.forward_path.lstrip%28%22%2F%22%29%0A%20%20%20%20if%20_has_control_chars%28forward_path%29%3A%0A%20%20%20%20%20%20%20%20raise%20HTTPException%28%0A%20%20%20%20%20%20%20%20%20%20%20%20status_code%3D400%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20detail%3D%22forward_path%20must%20not%20contain%20control%20characters.%22%2C%0A%20%20%20%20%20%20%20%20%29%0A%0A%20%20%20%20secret%20%3D%20request.secret%20if%20request.secret%20is%20not%20None%20else%20secrets.token_hex%2832%29%0A%20%20%20%20agent_api_key_entity%20%3D%20await%20agent_api_key_use_case.create%28%0A%20%20%20%20%20%20%20%20agent_id%3Dagent.id%2C%0A%20%20%20%20%20%20%20%20api_key%3Dstr%28secret%29%2C%0A%20%20%20%20%20%20%20%20name%3Drequest.name%2C%0A%20%20%20%20%20%20%20%20api_key_type%3Drequest.source%2C%0A%20%20%20%20%29%0A%60%60%60%0A%0A&repo=scaleapi%2Fscale-agentex&pr=329&platform=github"><picture><source media="(prefers-color-scheme: dark)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodexDark.svg?v=3"><source media="(prefers-color-scheme: light)" srcset="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=3"><img alt="Fix All in Codex" src="https://greptile-static-assets.s3.amazonaws.com/badges/FixAllInCodex.svg?v=3" height="20"></picture></a> <details><summary>Prompt To Fix All With AI</summary> `````markdown Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes. --- ### Issue 1 of 1 agentex/src/api/routes/agent_api_keys.py:163-176 **Key created before `forward_path` is validated** — if `forward_path` contains a control character, the DB write at line 164 has already committed before the 400 is raised. The caller receives an error response with no `key_id`, yet the key now exists under `(agent_id, name, source)`. Any subsequent corrected retry hits a 409 and is permanently blocked without deleting the orphaned key. Move the control-character check (and the `lstrip`) to before the `create()` call. ```suggestion 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, ) ``` ````` </details> <sub>Reviews (6): Last reviewed commit: ["chore(agent\_api\_keys): update openapi sp..."](be8a6ab) | [Re-trigger Greptile](https://app.greptile.com/api/retrigger?id=38966389)</sub> > Greptile also left **1 inline comment** on this PR. <!-- /greptile_comment --> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 13eea7a commit 9cc71fb

4 files changed

Lines changed: 464 additions & 0 deletions

File tree

agentex/openapi.yaml

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2722,6 +2722,47 @@ paths:
27222722
application/json:
27232723
schema:
27242724
$ref: '#/components/schemas/HTTPValidationError'
2725+
/agent_api_keys/webhook-trigger:
2726+
post:
2727+
tags:
2728+
- Agent APIKeys
2729+
summary: Create Webhook Trigger
2730+
description: 'Wire a webhook trigger in one call.
2731+
2732+
2733+
Registers the source''s signature-verification key (github/slack) for the
2734+
agent and
2735+
2736+
returns the ready-to-paste forward webhook URL plus the signing secret (shown
2737+
once).
2738+
2739+
The webhook then flows through the existing /agents/forward ingress, which
2740+
verifies
2741+
2742+
the signature against this key. Bundles the existing key-create + URL composition
2743+
so
2744+
2745+
a UI (or a curl) can set up a trigger without two steps.'
2746+
operationId: create_webhook_trigger_agent_api_keys_webhook_trigger_post
2747+
requestBody:
2748+
content:
2749+
application/json:
2750+
schema:
2751+
$ref: '#/components/schemas/CreateWebhookTriggerRequest'
2752+
required: true
2753+
responses:
2754+
'200':
2755+
description: Successful Response
2756+
content:
2757+
application/json:
2758+
schema:
2759+
$ref: '#/components/schemas/CreateWebhookTriggerResponse'
2760+
'422':
2761+
description: Validation Error
2762+
content:
2763+
application/json:
2764+
schema:
2765+
$ref: '#/components/schemas/HTTPValidationError'
27252766
/agent_api_keys/name/{name}:
27262767
get:
27272768
tags:
@@ -4549,6 +4590,93 @@ components:
45494590
to the agent inside the ACP payload for backward compatibility.
45504591
type: object
45514592
title: CreateTaskRequest
4593+
CreateWebhookTriggerRequest:
4594+
properties:
4595+
agent_name:
4596+
type: string
4597+
title: Agent Name
4598+
description: The agent the webhook drives.
4599+
source:
4600+
$ref: '#/components/schemas/AgentAPIKeyType'
4601+
description: Webhook source whose signature is verified (github or slack).
4602+
default: github
4603+
name:
4604+
type: string
4605+
title: Name
4606+
description: 'Signature-lookup key: the repo full_name (github) or api_app_id
4607+
(slack) that the forward ingress matches the incoming webhook against.'
4608+
forward_path:
4609+
type: string
4610+
title: Forward Path
4611+
description: Subpath the agent's own route handles, e.g. 'github-pr/<config-id>'.
4612+
Appended to /agents/forward/name/{agent_name}/ to form the webhook URL.
4613+
secret:
4614+
anyOf:
4615+
- type: string
4616+
- type: 'null'
4617+
title: Secret
4618+
description: Signing secret. For GitHub, omit to generate one, or provide
4619+
an existing webhook secret. For Slack, this is required and must be the
4620+
Slack app's Signing Secret.
4621+
base_url:
4622+
anyOf:
4623+
- type: string
4624+
- type: 'null'
4625+
title: Base Url
4626+
description: Optional public agentex base URL for the returned webhook_url;
4627+
defaults to the AGENTEX_PUBLIC_URL env var.
4628+
type: object
4629+
required:
4630+
- agent_name
4631+
- name
4632+
- forward_path
4633+
title: CreateWebhookTriggerRequest
4634+
description: 'One-call setup for a webhook trigger: register the source''s signature
4635+
key and
4636+
4637+
get back the ready-to-paste forward webhook URL.'
4638+
CreateWebhookTriggerResponse:
4639+
properties:
4640+
key_id:
4641+
type: string
4642+
title: Key Id
4643+
description: The created agent API key id.
4644+
agent_name:
4645+
type: string
4646+
title: Agent Name
4647+
description: The agent the webhook drives.
4648+
source:
4649+
$ref: '#/components/schemas/AgentAPIKeyType'
4650+
description: Webhook source (github or slack).
4651+
name:
4652+
type: string
4653+
title: Name
4654+
description: Signature-lookup key (repo full_name / api_app_id).
4655+
secret:
4656+
type: string
4657+
title: Secret
4658+
description: The signing secret — shown once; paste into the source's webhook
4659+
config.
4660+
webhook_path:
4661+
type: string
4662+
title: Webhook Path
4663+
description: The forward path to POST webhooks to.
4664+
webhook_url:
4665+
anyOf:
4666+
- type: string
4667+
- type: 'null'
4668+
title: Webhook Url
4669+
description: Full webhook URL to paste into the source (None if no base
4670+
URL configured).
4671+
type: object
4672+
required:
4673+
- key_id
4674+
- agent_name
4675+
- source
4676+
- name
4677+
- secret
4678+
- webhook_path
4679+
title: CreateWebhookTriggerResponse
45524680
DataContent:
45534681
properties:
45544682
type:

agentex/src/api/routes/agent_api_keys.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import os
12
import secrets
3+
from urllib.parse import quote
24

35
from fastapi import APIRouter, HTTPException, Query
46

@@ -7,6 +9,8 @@
79
AgentAPIKey,
810
CreateAPIKeyRequest,
911
CreateAPIKeyResponse,
12+
CreateWebhookTriggerRequest,
13+
CreateWebhookTriggerResponse,
1014
)
1115
from src.api.schemas.authorization_types import (
1216
AgentexResourceType,
@@ -35,6 +39,10 @@
3539
)
3640

3741

42+
def _has_control_chars(value: str) -> bool:
43+
return any(ord(char) < 32 or ord(char) == 127 for char in value)
44+
45+
3846
@router.post(
3947
"",
4048
response_model=CreateAPIKeyResponse,
@@ -93,6 +101,99 @@ async def create_api_key(
93101
)
94102

95103

104+
@router.post(
105+
"/webhook-trigger",
106+
response_model=CreateWebhookTriggerResponse,
107+
)
108+
async def create_webhook_trigger(
109+
request: CreateWebhookTriggerRequest,
110+
agent_api_key_use_case: DAgentAPIKeysUseCase,
111+
agent_use_case: DAgentsUseCase,
112+
authorization_service: DAuthorizationService,
113+
) -> CreateWebhookTriggerResponse:
114+
"""Wire a webhook trigger in one call.
115+
116+
Registers the source's signature-verification key (github/slack) for the agent and
117+
returns the ready-to-paste forward webhook URL plus the signing secret (shown once).
118+
The webhook then flows through the existing /agents/forward ingress, which verifies
119+
the signature against this key. Bundles the existing key-create + URL composition so
120+
a UI (or a curl) can set up a trigger without two steps.
121+
"""
122+
if request.source not in (AgentAPIKeyType.GITHUB, AgentAPIKeyType.SLACK):
123+
raise HTTPException(
124+
status_code=400,
125+
detail="source must be 'github' or 'slack' for a webhook trigger.",
126+
)
127+
agent = await agent_use_case.get(name=request.agent_name)
128+
129+
# No api_key resource exists yet, so gate on the parent agent (update).
130+
await _check_agent_or_collapse_to_404(
131+
authorization_service,
132+
agent.id,
133+
AuthorizedOperationType.update,
134+
)
135+
136+
existing_api_key = await agent_api_key_use_case.get_by_agent_id_and_name(
137+
agent_id=agent.id,
138+
name=request.name,
139+
api_key_type=request.source,
140+
)
141+
if existing_api_key:
142+
# A duplicate is an expected client condition (409), not a server error, and the
143+
# message avoids leaking the internal agent UUID the caller never supplied.
144+
raise HTTPException(
145+
status_code=409,
146+
detail=f"A {request.source} webhook key named '{request.name}' already exists for this agent.",
147+
)
148+
149+
# GitHub lets you supply (and we can generate) a per-webhook secret to paste into the
150+
# repo's Secret field. Slack is different: it signs every request with the app's own
151+
# Signing Secret, so the caller must supply that exact value — a generated one would
152+
# never match, and validate_slack_delivery_webhook would reject every real delivery.
153+
# (See PR #329 discussion.)
154+
if request.source == AgentAPIKeyType.SLACK and not request.secret:
155+
raise HTTPException(
156+
status_code=400,
157+
detail=(
158+
"Slack triggers must supply 'secret' set to the Slack app's Signing Secret "
159+
"(from your app credentials); it can't be generated."
160+
),
161+
)
162+
163+
forward_path = request.forward_path.lstrip("/")
164+
if _has_control_chars(forward_path):
165+
raise HTTPException(
166+
status_code=400,
167+
detail="forward_path must not contain control characters.",
168+
)
169+
170+
secret = request.secret if request.secret is not None else secrets.token_hex(32)
171+
agent_api_key_entity = await agent_api_key_use_case.create(
172+
agent_id=agent.id,
173+
api_key=str(secret),
174+
name=request.name,
175+
api_key_type=request.source,
176+
)
177+
178+
encoded_agent_name = quote(request.agent_name, safe="")
179+
encoded_forward_path = quote(forward_path, safe="/")
180+
webhook_path = f"/agents/forward/name/{encoded_agent_name}/{encoded_forward_path}"
181+
base_url = (request.base_url or os.environ.get("AGENTEX_PUBLIC_URL", "")).rstrip(
182+
"/"
183+
)
184+
webhook_url = f"{base_url}{webhook_path}" if base_url else None
185+
186+
return CreateWebhookTriggerResponse(
187+
key_id=agent_api_key_entity.id,
188+
agent_name=request.agent_name,
189+
source=agent_api_key_entity.api_key_type,
190+
name=request.name,
191+
secret=str(secret),
192+
webhook_path=webhook_path,
193+
webhook_url=webhook_url,
194+
)
195+
196+
96197
@router.get(
97198
"",
98199
response_model=list[AgentAPIKey],

agentex/src/api/schemas/agent_api_keys.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,57 @@ 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=(
80+
"Signing secret. For GitHub, omit to generate one, or provide an existing "
81+
"webhook secret. For Slack, this is required and must be the Slack app's "
82+
"Signing Secret."
83+
),
84+
)
85+
base_url: str | None = Field(
86+
None,
87+
description="Optional public agentex base URL for the returned webhook_url; "
88+
"defaults to the AGENTEX_PUBLIC_URL env var.",
89+
)
90+
91+
92+
class CreateWebhookTriggerResponse(BaseModel):
93+
key_id: str = Field(..., description="The created agent API key id.")
94+
agent_name: str = Field(..., description="The agent the webhook drives.")
95+
source: AgentAPIKeyType = Field(
96+
..., description="Webhook source (github or slack)."
97+
)
98+
name: str = Field(
99+
..., description="Signature-lookup key (repo full_name / api_app_id)."
100+
)
101+
secret: str = Field(
102+
...,
103+
description="The signing secret — shown once; paste into the source's webhook config.",
104+
)
105+
webhook_path: str = Field(..., description="The forward path to POST webhooks to.")
106+
webhook_url: str | None = Field(
107+
None,
108+
description="Full webhook URL to paste into the source (None if no base URL configured).",
109+
)

0 commit comments

Comments
 (0)