* feat(webhooks): HTTP webhooks to trigger agents with HMAC auth and durable callbacks
Add multi-tenant HTTP webhook endpoints for agent triggering:
- /v1/webhooks/message: send messages to channels
- /v1/webhooks/llm: sync/async LLM prompts with HMAC-signed callbacks
- HMAC-256 + bearer token authentication
- Rate limiting and tenant isolation
- Durable callback worker with exponential backoff
- PG 000056 + SQLite schema v25 migrations
- Unit + integration tests, P0 tenant isolation invariants
- Channel media capability helpers for attachment routing
- Comprehensive webhook documentation and i18n strings
* fix(webhooks): address post-review findings (K1-K10)
Comprehensive post-merge fixes addressing 10 blocking code review issues
and 2 adversarial re-audit findings in webhook-agent-triggering feature:
K1: Fix auth middleware tenant context lookup sequencing — move
tenant context injection before authenticate() call to prevent
unscoped secret lookups.
K2: Canonicalize JSON payload format for jsonb compatibility across
PostgreSQL and SQLite — ensure consistent serialization without
whitespace variance to prevent hash mismatches.
K3: Add fail-closed JSON parsing in body hash extraction with explicit
error handling for malformed payloads before HMAC verification.
K4: Fix worker queue wedge by properly draining slot reservations
when delivery succeeds, preventing permanent slot occupancy.
K5: Implement lease-token optimistic concurrency control to prevent
duplicate webhook delivery under high concurrency or retry storms.
K6: Add AES-256-GCM encrypted secret storage at rest with fail-fast
skip-mount when GOCLAW_ENCRYPTION_KEY environment variable unset.
K7: Implement IP allowlist enforcement supporting both CIDR ranges
and exact IP matching with proper X-Forwarded-For parsing.
K8: Add HMAC replay nonce cache (5min expiry, non-blocking async flush)
to prevent request replay attacks on webhook handler.
K9: Fix invariant test schema selection — replace hardcoded assumption
with explicit schema name from config to support multi-schema testing.
K10: Consolidate rate limiters into single shared instance to prevent
per-endpoint limiter starvation and ensure fair rate limiting.
New database migrations:
- 000057: webhook_calls.lease_token for optimistic concurrency
- 000058: webhooks.encrypted_secret_key for AES-256-GCM encryption
New i18n keys: MsgWebhookIPDenied, MsgWebhookEncryptionUnavailable
(with English, Vietnamese, Chinese translations).
New modules:
- internal/http/webhooks_payload.go: JSON canonicalization + body hash
- internal/http/webhooks_nonce.go: Replay nonce cache implementation
- internal/http/webhooks_idempotency_test.go: Integration tests
Documentation updates:
- docs/webhooks.md: §13-14 security sections, encryption flow
- docs/00-architecture-overview.md: webhook subsystem security overview
- docs/codebase-summary.md: webhook security patterns
- docs/project-changelog.md: webhook fixes changelog
Test coverage: 53 webhook tests + 4 P0 invariant tests all passing.
No tenant isolation violations. All security gates enforced.
* docs(journals): webhook feature ship + fix cycle entries
* fix(webhooks): address Claude review findings
- webhooks_llm.go: remove misleading ptr() helper; use &completedAt
pattern for error-path audit rows (matches success path)
- webhooks_auth.go: wrap TouchLastUsed context in WithoutCancel so
background DB update isn't cancelled when HTTP response completes
- store GetByIDUnscoped (PG+SQLite): add NOT revoked / revoked = 0
filter for defense-in-depth parity with GetByHashUnscoped
- webhooks/sign.go: fix package doc — HMAC key is raw plaintext
secret bytes, not hex-decoded SHA-256
- webhooks_admin.go: check auth before encKey guard to avoid leaking
config state to unauthenticated callers
- webhooks_ratelimit.go: two-phase Load→LoadOrStore to avoid per-call
entry allocation on the hot path
* docs(webhooks): fix Sign() function doc to match actual key input
Function-level comment still referenced hex-decoded SecretHash after
the package-level doc was corrected. Align with actual caller usage
([]byte(rawSecret)).
* fix(webhooks): use WithoutCancel for worker execute DB updates
Terminal status writes in execute() ran through the worker main-loop
ctx, which is cancelled on graceful shutdown. If the outbound send
completed but the status update raced with shutdown, the row stayed
in 'running' and got re-delivered via reclaimStale. WithoutCancel
lets the DB write survive worker cancellation while preserving
propagated values (tenant ID, etc.).
* fix(webhooks): move tctx init before panic defer in worker execute
Panic recovery called updateRetry with raw ctx (no tenant ID), making
requireTenantID fail and the reset-to-retry DB write silently drop.
Row stayed 'running' until reclaimStale (~90s delay). Init tctx first
so defer closure captures tenant-scoped non-cancellable context.
* fix(webhooks): pass tenant-scoped tctx to invokeAgent in worker
execute() was passing the raw worker-loop ctx (no tenant ID) to
invokeAgent → router.Get → PGAgentStore.GetByID. GetByID reads
TenantIDFromContext which returned uuid.Nil, making every lookup
return 'agent not found'. Async LLM webhook calls silently failed
all retries. Pass tctx (already tenant-scoped + WithoutCancel) so
the router resolves the agent correctly.
* fix(tests): resolve integration test compile errors
- Remove duplicate contains() in mcp_grant_revoke_test.go (already
defined in tts_gemini_live_test.go)
- Update webhooks_admin_test.go RotateSecret call to match current
5-arg signature (newSecretHash, newPrefix, newEncryptedSecret)
* fix(webhooks): default nil scopes/ip_allowlist to empty slice in Create
PG columns are NOT NULL DEFAULT '{}'. Explicit NULL from pqStringArray(nil)
violated the constraint, breaking TestWebhookAdminCRUD/TenantIsolation.
Coerce nil slices to empty []string{} so the default applies at the DB layer.
* chore: trigger CI on digitopvn/goclaw fork
* ci: retrigger workflows
* fix(webhooks): renumber migrations to 000059-000061 for merge train
11 KiB
Codebase Summary
High-level map of GoClaw modules and key cross-cutting concerns.
For system design see docs/00-architecture-overview.md; for API contract see docs/18-http-api.md.
Module Map
| Path | Purpose |
|---|---|
cmd/ |
CLI (cobra): serve, onboard, migrate commands |
internal/agent/ |
Agent loop (think→act→observe), router, input guard |
internal/audio/ |
TTS provider layer (see §TTS below) |
internal/bootstrap/ |
SOUL/IDENTITY system prompts + per-user seed |
internal/channels/ |
Telegram, Feishu, Zalo, Discord, WhatsApp connectors |
internal/config/ |
JSON5 config loading + env overlay |
internal/crypto/ |
AES-256-GCM encryption for API keys |
internal/gateway/ |
WS + HTTP server, client, method router |
internal/http/ |
HTTP API handlers (/v1/*) |
internal/i18n/ |
Backend message catalog (EN/VI/ZH) + T(locale, key, args) |
internal/memory/ |
pgvector 3-tier memory system |
internal/mcp/ |
Model Context Protocol bridge |
internal/permissions/ |
RBAC: admin / operator / viewer |
internal/pipeline/ |
8-stage agent pipeline |
internal/providers/ |
LLM providers (Anthropic, OpenAI-compat, Qwen, Claude CLI) |
internal/store/ |
Store interfaces + PG + SQLite implementations |
internal/tools/ |
Tool registry (filesystem, exec, web, MCP, delegate) |
internal/tts/ |
Back-compat alias package for old import paths |
internal/vault/ |
Knowledge Vault: wikilinks, hybrid search, FS sync |
migrations/ |
PostgreSQL migration files |
ui/web/ |
React SPA (Vite, Tailwind, Radix UI, Zustand) |
ui/desktop/ |
Wails v2 desktop app (SQLite, embedded gateway) |
TTS Subsystem
Overview
TTS lives in internal/audio/ (canonical) with a back-compat alias at internal/tts/.
Five providers ship out-of-the-box: OpenAI, ElevenLabs, Edge TTS, MiniMax, Gemini.
Provider Capabilities Schema
Each provider implements audio.TTSProvider and optionally audio.CapabilitiesProvider:
type CapabilitiesProvider interface {
Capabilities() ProviderCapabilities
}
type ProviderCapabilities struct {
Params []ParamSchema
CustomFeatures CustomFeatures
}
ParamSchema carries Key, Type (string/number/bool/select/textarea), Label, Help, Default,
Min/Max, Options (for select), DependsOn (all-of conditions), and Hidden flag.
The UI reads GET /v1/tts/capabilities and renders per-provider param editors dynamically.
See docs/tts-provider-capabilities.md for full schema documentation.
Providers
| Provider | Package | Voice source | Key param |
|---|---|---|---|
| OpenAI | internal/audio/openai/ |
Static list | speed, response_format, instructions |
| ElevenLabs | internal/audio/elevenlabs/ |
Dynamic (API) | voiceSettings.*, seed, language_code |
| Edge TTS | internal/audio/edge/ |
Static list | rate, pitch, volume |
| MiniMax | internal/audio/minimax/ |
Dynamic (API) | speed, vol, pitch, emotion, audio.* |
| Gemini | internal/audio/gemini/ |
Static list (30) | multi-speaker, audio tags, 70+ languages |
Storage: Dual-Read
Tenant TTS config is stored in system_config with two complementary strategies:
- Legacy flat keys (
tts.provider,tts.voice_id,tts.api_key, …) — written by all versions. - Params blob (
tts.<provider>.paramsJSON) — written by v3.x+ for per-provider param overrides.
On read, both paths are merged; blob wins on key conflict. This allows gradual migration: old clients see flat keys; new clients see the full params blob.
Manager
audio.Manager is the central registry:
RegisterProvider(p TTSProvider)— replaces any existing provider by the same name.Primary()— returns current primary provider name from config.Synthesize(ctx, text, opts)— delegates to active provider.ListVoices(ctx, opts)— delegates toVoiceListProviderif implemented.
HTTP Endpoints
| Endpoint | Role | Notes |
|---|---|---|
POST /v1/tts/synthesize |
operator | Streams WAV response |
POST /v1/tts/test-connection |
operator | Ephemeral provider, returns latency |
GET /v1/tts/capabilities |
operator | ProviderCapabilities JSON per provider |
GET /v1/tts/config |
admin | Tenant TTS config |
POST /v1/tts/config |
admin | Save tenant TTS config |
Gemini Specifics
- Models:
gemini-3.1-flash-tts-preview(default),gemini-2.5-flash-preview-tts,gemini-2.5-pro-preview-tts(preview). - Multi-speaker: up to 2 simultaneous speakers, each with distinct voice + name annotation.
- Audio tags: inline
<say-as>/ style directives via bracketed prompts. - Sentinel errors:
ErrInvalidVoice,ErrInvalidModel,ErrSpeakerLimit→ HTTP 422 with i18n message. - SSRF guard:
api_baseoverride validated byvalidateProviderURL()— blocks 127.0.0.1/localhost.
i18n
Backend validation errors use i18n.T(locale, key, args...) pattern.
Locale is extracted from Accept-Language HTTP header by enrichContext middleware.
UI param labels/help text live in:
ui/web/src/i18n/locales/{en,vi,zh}/tts.jsonui/desktop/frontend/src/i18n/locales/{en,vi,zh}/tts.json
Parity enforced by ui/web/src/__tests__/i18n-tts-key-parity.test.ts (vitest).
<<<<<<< HEAD
Image Generation
Native image_generation support in the Codex provider (POST /codex/responses) + passthrough in the OpenAI-compat path.
Provider flag: ProviderCapabilities.ImageGeneration bool (internal/providers/capabilities.go). Codex sets true; other providers default false.
Gate (agent loop): ToolDefinition{Type:"image_generation"} appended iff (provider capability) AND (AgentConfig.AllowImageGeneration, default true) AND (request lacks x-goclaw-no-image-gen header). Gate logic in internal/agent/loop_tool_filter.go.
Codex native events (internal/providers/codex.go):
response.image_generation_call.partial_image→ChatResponse.Imagesentry withPartial:true.response.output_item.donewithitem.type == "image_generation_call"→ finalChatResponse.Imagesentry; partial frames for sameitem_idreplaced.response.completedwalksresponse.output[]for image items (non-stream).
OpenAI-compat parsing: choices[0].message.images[] + choices[0].delta.images[] with data:image/...;base64,... URLs decoded in internal/providers/openai_http.go and internal/providers/openai_chat.go. Helper: parseDataURL() in internal/providers/openai_image_url.go.
Persistence: internal/agent/media.go persistAssistantImages() writes final images to {workspace}/media/{sha256}.{ext}, returns MediaRef entries, clears inline Images[]. Idempotent on hash. Invoked from pipeline.FinalizeStage via Deps.PersistAssistantImages callback.
Web UI: Download filename resolver (imageGenDownloadName) in ui/web/src/components/chat/media-gallery.tsx. Image generation works automatically when the agent has the create_image tool — no user-facing toggle.
Webhook Subsystem
External systems invoke agents or send channel messages via webhooks without gateway tokens.
Components
| Path | Purpose |
|---|---|
internal/http/webhooks_admin.go |
CRUD handlers (create, list, get, patch, rotate, revoke) |
internal/http/webhooks_auth.go |
Bearer + HMAC signature verification, IPAllowlist, tenant scope |
internal/http/webhooks_nonce.go |
Per-process HMAC replay cache (320s TTL) |
internal/http/webhooks_llm.go |
POST /v1/webhooks/llm endpoint (sync 30s / async) |
internal/http/webhooks_message.go |
POST /v1/webhooks/message endpoint (channel delivery) |
internal/http/webhooks_ratelimit.go |
Per-webhook + per-tenant rate limiting |
internal/http/webhooks_idempotency.go |
Idempotency-Key header dedup cache (24h TTL) |
internal/http/webhooks_media_fetch.go |
SSRF-guarded media URL fetch + MIME validation |
internal/webhooks/worker.go |
Async callback poller + delivery goroutines |
internal/webhooks/backoff.go |
Exponential retry schedule [30s, 2m, 10m, 1h, 6h] |
internal/webhooks/sign.go |
HMAC-SHA256 signing for outbound callbacks |
internal/webhooks/limiter.go |
Shared rate limiter for callback delivery |
internal/store/webhook_store.go |
WebhookStore interface + WebhookCallStore |
internal/store/pg/webhook_store.go |
PostgreSQL implementation (tenant-scoped) |
internal/store/sqlitestore/webhook_store.go |
SQLite implementation (Lite edition) |
migrations/ |
PG migrations 000056–000058 (webhooks + lease token + encrypted secret) |
Auth Flow
- Bearer auth: Hash the token, lookup
secret_hashglobally (viaGetByHashUnscoped) → return webhook + tenantID. - HMAC auth: Parse
X-Webhook-Idheader, lookup webhook globally → verify signature timestamp + nonce. - Tenant inject: Re-scope context with webhook's tenantID for all downstream calls.
- IP allowlist: If non-empty, check request source IP (CIDR or exact) against list. Empty = allow all.
- Rate limit: Check per-webhook + per-tenant buckets. Either rejects = 429.
Idempotency & Lease Tokens
- Inbound:
Idempotency-Keyheader dedup (24h cache). Same key + same body = cached response; same key + different body = 409 Conflict. - Outbound: Each
webhook_callsrow haslease_token(UUID). Worker claims row with CAS. On update, token proves ownership — prevents stale receivers from overwriting.
Secret Encryption
Raw webhook secret encrypted at rest via AES-256-GCM using GOCLAW_ENCRYPTION_KEY (same as LLM provider keys).
- Database: stores
encrypted_secretcolumn +secret_hash(for bearer lookups). - DB compromise does not leak HMAC material.
- Clients receive plaintext secret once (create/rotate response) — must store securely.
Audit Payload
All webhook calls logged with canonical {"body_hash":"<sha256-hex>","meta":{...}} shape in webhook_calls.request_payload (JSON).
Used by idempotency checker to detect body mismatches on replay.
a83f4090 (fix(webhooks): address post-review findings (K1-K10))
Key Conventions
- Store layer: Interface-based; PG (
store/pg/) + SQLite (store/sqlitestore/). Raw SQL,$1/$2params. - Session token display: v3 compaction now uses dynamic max_tokens (
in/25clamped[1024,8192]); session token display reads fromsessions.metadata.last_prompt_tokensandlast_message_count. Tool schemas counted viaTokenCounter.CountToolSchemas()and included in ContextStage overhead. - Context propagation:
store.WithLocale,store.WithUserID,store.WithTenantID, etc. - Security logs:
slog.Warn("security.*")for all security events. - SSRF prevention:
validateProviderURL()ininternal/http/tts_validate.go. - i18n keys: Add to
internal/i18n/keys.go+ 3 catalog Go files; UI strings in 3 locale JSON dirs. - Migrations: PG (
migrations/) + SQLite (store/sqlitestore/schema.go) — always update both.